Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { AuthzGrantMeta, AuthzPolicyMeta, PgCodec } from './types';

/**
* Raw @authz tag entry as stored in smart_tags jsonb / PostgreSQL COMMENT ON.
* Each entry maps 1:1 to an apply_rls() call.
*/
interface RawAuthzEntry {
grants?: [string, string][];
policy_type?: string;
vars?: Record<string, unknown>;
name?: string;
permissive?: boolean;
field_names?: string[];
}

/**
* Human-readable description generators keyed by policy_type.
* Each function receives the vars object and returns a description string.
*/
const POLICY_DESCRIPTIONS: Record<string, (vars: Record<string, unknown>) => string> = {
AuthzAllowAll: () => 'Allows all access',
AuthzDenyAll: () => 'Denies all access',
AuthzDirectOwner: (vars) => {
const field = vars.entity_field || 'owner_id';
return `Requires direct ownership via ${field}`;
},
AuthzDirectOwnerAny: (vars) => {
const fields = Array.isArray(vars.entity_fields) ? vars.entity_fields.join(', ') : 'owner fields';
return `Requires ownership via any of: ${fields}`;
},
AuthzMembership: (vars) => {
const parts = ['Requires app membership'];
if (vars.permission) parts.push(`with ${vars.permission} permission`);
if (vars.permissions && Array.isArray(vars.permissions)) parts.push(`with ${vars.permissions.join(' or ')} permission`);
if (vars.is_admin) parts.push('(admin)');
if (vars.is_owner) parts.push('(owner)');
return parts.join(' ');
},
AuthzEntityMembership: (vars) => {
const field = vars.entity_field || 'entity_id';
const parts = [`Requires membership on entity referenced by ${field}`];
if (vars.permission) parts.push(`with ${vars.permission} permission`);
if (vars.permissions && Array.isArray(vars.permissions)) parts.push(`with ${vars.permissions.join(' or ')} permission`);
if (vars.is_admin) parts.push('(admin)');
if (vars.is_owner) parts.push('(owner)');
return parts.join(' ');
},
AuthzRelatedEntityMembership: (vars) => {
const field = vars.entity_field || 'entity_id';
return `Requires membership via related entity on ${field}`;
},
AuthzOrgHierarchy: (vars) => {
const dir = vars.direction === 'up' ? 'managers' : 'subordinates';
return `Org hierarchy access (${dir} can view)`;
},
AuthzTemporal: (vars) => {
const parts = ['Time-window access'];
if (vars.valid_from_field) parts.push(`from ${vars.valid_from_field}`);
if (vars.valid_until_field) parts.push(`until ${vars.valid_until_field}`);
return parts.join(' ');
},
AuthzPublishable: () => 'Requires published state',
AuthzMemberList: (vars) => {
const field = vars.array_field || 'member_ids';
return `Requires user in ${field} array`;
},
AuthzRelatedMemberList: (vars) => {
const table = vars.owned_table || 'related table';
return `Requires user in member list on ${table}`;
},
AuthzComposite: (vars) => {
const op = vars.bool_op === 'or' ? 'OR' : 'AND';
return `Composite policy (${op})`;
},
};

function describePolicy(policyType: string, vars: Record<string, unknown>): string {
const describer = POLICY_DESCRIPTIONS[policyType];
if (describer) return describer(vars);
return policyType;
}

function buildGrantMeta(rawGrant: [string, string]): AuthzGrantMeta {
return {
privilege: rawGrant[0],
role: rawGrant[1],
};
}

function buildPolicyMeta(entry: RawAuthzEntry): AuthzPolicyMeta | null {
const policyType = entry.policy_type;
if (!policyType) return null;

const vars = entry.vars || {};
const grants = Array.isArray(entry.grants)
? entry.grants.map(buildGrantMeta)
: [];

return {
policyType,
description: describePolicy(policyType, vars),
grants,
permissive: entry.permissive !== false,
...(entry.name ? { name: entry.name } : {}),
};
}

/**
* Extract @authz smart tag from a PostGraphile codec's extensions
* and transform into AuthzPolicyMeta[].
*
* PostGraphile v5 stores smart tags from COMMENT ON as codec.extensions.tags.
* The metaschema stores @authz as a JSON array in smart_tags jsonb,
* which flows through to the codec via the PostGraphile introspection.
*/
export function buildAuthzMeta(codec: PgCodec): AuthzPolicyMeta[] | undefined {
const tags = (codec as PgCodecWithTags).extensions?.tags;
if (!tags) return undefined;

const authzRaw = tags.authz;
if (!authzRaw) return undefined;

// authz can be a JSON string or already-parsed array
let entries: RawAuthzEntry[];
if (typeof authzRaw === 'string') {
try {
entries = JSON.parse(authzRaw);
} catch {
return undefined;
}
} else if (Array.isArray(authzRaw)) {
entries = authzRaw as RawAuthzEntry[];
} else {
return undefined;
}

if (!Array.isArray(entries) || entries.length === 0) return undefined;

const policies = entries
.map(buildPolicyMeta)
.filter((p): p is AuthzPolicyMeta => p !== null);

return policies.length > 0 ? policies : undefined;
}

/**
* Extended PgCodec type that includes tags in extensions.
* PostGraphile v5 stores smart tags from COMMENT ON in extensions.tags.
*/
interface PgCodecWithTags extends PgCodec {
extensions?: PgCodec['extensions'] & {
tags?: Record<string, unknown>;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,27 @@ function createMetaSchemaType(): GraphQLObjectType {
}),
});

const MetaAuthzGrantType = new GraphQLObjectType({
name: 'MetaAuthzGrant',
description: 'A privilege/role grant pair for an authorization policy',
fields: () => ({
privilege: { type: nn(GraphQLString) },
role: { type: nn(GraphQLString) },
}),
});

const MetaAuthzPolicyType = new GraphQLObjectType({
name: 'MetaAuthzPolicy',
description: 'Authorization policy applied to a table',
fields: () => ({
policyType: { type: nn(GraphQLString) },
description: { type: nn(GraphQLString) },
grants: { type: nnList(MetaAuthzGrantType) },
permissive: { type: nn(GraphQLBoolean) },
name: { type: GraphQLString },
}),
});

const MetaTableType = new GraphQLObjectType({
name: 'MetaTable',
description: 'Information about a database table',
Expand All @@ -205,6 +226,7 @@ function createMetaSchemaType(): GraphQLObjectType {
relations: { type: nn(MetaRelationsType) },
inflection: { type: nn(MetaInflectionType) },
query: { type: nn(MetaQueryType) },
authz: { type: new GraphQLList(nn(MetaAuthzPolicyType)) },
}),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
buildReverseRelations,
} from './relation-meta-builders';
import { buildFieldMeta } from './type-mappings';
import { buildAuthzMeta } from './authz-meta-builder';
import {
createBuildContext,
type BuildContext,
Expand Down Expand Up @@ -74,6 +75,8 @@ function buildTableMeta(

const tableType = resolveTableType(context.build, codec);

const authz = buildAuthzMeta(codec);

return {
name: tableType,
schemaName,
Expand All @@ -86,6 +89,7 @@ function buildTableMeta(
relations: relationsMeta,
inflection: buildInflectionMeta(resource, tableType, context.build),
query: buildQueryMeta(resource, uniques, tableType, context.build),
...(authz ? { authz } : {}),
};
}

Expand Down
14 changes: 14 additions & 0 deletions graphile/graphile-settings/src/plugins/meta-schema/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
export interface AuthzGrantMeta {
privilege: string;
role: string;
}

export interface AuthzPolicyMeta {
policyType: string;
description: string;
grants: AuthzGrantMeta[];
permissive: boolean;
name?: string;
}

export interface TableMeta {
name: string;
schemaName: string;
Expand All @@ -10,6 +23,7 @@ export interface TableMeta {
relations: RelationsMeta;
inflection: InflectionMeta;
query: QueryMeta;
authz?: AuthzPolicyMeta[];
}

export interface FieldMeta {
Expand Down
Loading