From 426e4a1640f0a1afe14d10d386682c2017c7de1c Mon Sep 17 00:00:00 2001 From: Ivan Chikishev Date: Wed, 7 Oct 2026 18:29:08 +0500 Subject: [PATCH 1/2] feat: add system user relations and policy-managed application access --- .../20261007030000_oauth_policy_access.cjs | 67 ++ .../20261007040000_system_user_relations.cjs | 20 + apps/core/src/auth/user-references.ts | 91 ++ apps/core/src/auth/user-routes.ts | 12 + .../src/collections/catalog-repository.ts | 6 +- apps/core/src/collections/editable-field.ts | 4 +- .../collections/field-behavior-validation.ts | 8 + apps/core/src/collections/field-default.ts | 5 + apps/core/src/collections/relation-input.ts | 21 +- apps/core/src/collections/relation-search.ts | 11 + apps/core/src/collections/relations.ts | 30 +- apps/core/src/items/field-rule-service.ts | 9 + apps/core/src/items/record-draft.ts | 6 + apps/core/src/items/related.ts | 1 + apps/core/src/items/schema-repository.ts | 5 +- apps/core/src/items/user-reference-writes.ts | 42 + apps/core/src/oauth/applications.ts | 56 ++ apps/core/src/oauth/consents.ts | 50 +- apps/core/src/oauth/grant-bindings.ts | 3 +- apps/core/src/oauth/input.ts | 30 + apps/core/src/oauth/interactions.ts | 20 + apps/core/src/oauth/policy-access.ts | 100 +++ apps/core/src/oauth/provider.ts | 20 +- apps/core/src/oauth/token-permissions.ts | 36 + .../src/policies/configuration-permissions.ts | 242 +++++ .../src/policies/configuration-repository.ts | 254 +----- .../src/policies/configuration-validation.ts | 48 +- apps/core/src/policies/repository.ts | 8 +- apps/core/src/policies/routes.ts | 4 + .../test/oauth-consents.integration.test.ts | 5 + .../test/oauth-policies.integration.test.ts | 292 ++++++ ...oauth-policy-migration.integration.test.ts | 49 ++ apps/core/test/support/oauth-provider.ts | 11 +- .../test/user-relations.integration.test.ts | 289 ++++++ .../components/access/policy-applications.tsx | 204 +++++ .../access/policy-editor-dialog.tsx | 75 +- .../collections/field-rules-settings.tsx | 132 +-- .../relation-behavior-settings.tsx | 16 +- .../collections/relation-create-form.tsx | 12 +- .../collections/use-field-editor.ts | 5 +- .../items/item-filter-field-picker.tsx | 4 +- .../components/items/item-filter-options.ts | 13 + .../items/item-filter-relation-value.tsx | 13 +- .../components/items/item-readonly-field.tsx | 15 +- .../src/components/items/relation-picker.tsx | 10 +- .../src/components/items/relation-value.tsx | 2 + .../items/table-relation-requests.ts | 8 +- .../items/use-filter-relation-items.ts | 3 +- .../components/items/use-relation-items.ts | 5 +- .../items/use-table-relation-labels.ts | 25 + .../ui/src/components/items/user-reference.ts | 46 + .../ui/src/components/oauth/access-summary.ts | 3 + .../components/oauth/application-editor.tsx | 22 +- .../components/oauth/application-fields.tsx | 62 +- .../oauth/application-permissions.tsx | 110 +++ apps/ui/src/components/oauth/consent.tsx | 17 +- apps/ui/src/components/oauth/types.ts | 2 + .../settings/connected-apps-panel.tsx | 21 +- apps/ui/test/user-reference.test.ts | 63 ++ docs/features/access.md | 22 + docs/features/data.md | 41 + docs/features/integrations.md | 48 + docs/public/openapi.json | 832 +++++++++++++++++- docs/reference/endpoints.json | 8 + docs/reference/routes.md | 4 +- examples/oauth-lavinmq/README.md | 9 + packages/contracts/locales/ui.en.json | 26 +- packages/contracts/locales/ui.ru.json | 26 +- scripts/docs/browser-auth-contract.mjs | 7 + scripts/docs/http-contracts.mjs | 6 + scripts/docs/oauth-policy-contract.mjs | 121 +++ scripts/docs/user-reference-contract.mjs | 73 ++ scripts/test.mjs | 2 +- 73 files changed, 3542 insertions(+), 426 deletions(-) create mode 100644 apps/core/migrations/20261007030000_oauth_policy_access.cjs create mode 100644 apps/core/migrations/20261007040000_system_user_relations.cjs create mode 100644 apps/core/src/auth/user-references.ts create mode 100644 apps/core/src/items/user-reference-writes.ts create mode 100644 apps/core/src/oauth/policy-access.ts create mode 100644 apps/core/src/oauth/token-permissions.ts create mode 100644 apps/core/src/policies/configuration-permissions.ts create mode 100644 apps/core/test/oauth-policies.integration.test.ts create mode 100644 apps/core/test/oauth-policy-migration.integration.test.ts create mode 100644 apps/core/test/user-relations.integration.test.ts create mode 100644 apps/ui/src/components/access/policy-applications.tsx create mode 100644 apps/ui/src/components/items/user-reference.ts create mode 100644 apps/ui/src/components/oauth/application-permissions.tsx create mode 100644 apps/ui/test/user-reference.test.ts create mode 100644 scripts/docs/oauth-policy-contract.mjs create mode 100644 scripts/docs/user-reference-contract.mjs diff --git a/apps/core/migrations/20261007030000_oauth_policy_access.cjs b/apps/core/migrations/20261007030000_oauth_policy_access.cjs new file mode 100644 index 0000000..7a880c2 --- /dev/null +++ b/apps/core/migrations/20261007030000_oauth_policy_access.cjs @@ -0,0 +1,67 @@ +exports.up = async function (knex) { + await knex.schema + .withSchema("public") + .alterTable("asmblyr_oauth_apps", (table) => { + table.boolean("policy_managed").notNullable().defaultTo(false); + table.jsonb("scope_labels").notNullable().defaultTo("{}"); + }); + await knex.schema + .withSchema("public") + .createTable("asmblyr_policy_oauth_apps", (table) => { + table + .uuid("policy_id") + .notNullable() + .references("id") + .inTable("public.asmblyr_policies") + .onDelete("CASCADE"); + table + .uuid("app_id") + .notNullable() + .references("id") + .inTable("public.asmblyr_oauth_apps") + .onDelete("CASCADE"); + table.jsonb("scopes").notNullable().defaultTo("[]"); + table.primary(["policy_id", "app_id"]); + table.index("app_id"); + }); + for (const name of ["asmblyr_oauth_consents", "asmblyr_oauth_grants"]) { + await knex.schema.withSchema("public").alterTable(name, (table) => { + table.jsonb("service_scopes").notNullable().defaultTo("[]"); + }); + } +}; + +exports.down = async function (knex) { + await knex.raw(`LOCK TABLE public.asmblyr_oauth_apps, public.asmblyr_oauth_consents, + public.asmblyr_oauth_grants, public.asmblyr_policy_oauth_apps IN ACCESS EXCLUSIVE MODE`); + const configured = await knex("public.asmblyr_oauth_apps") + .where({ policy_managed: true }) + .orWhereRaw("scope_labels <> '{}'::jsonb") + .first(); + const approved = await knex("public.asmblyr_oauth_consents") + .whereRaw("service_scopes <> '[]'::jsonb") + .first(); + const pending = await knex("public.asmblyr_oauth_grants") + .whereRaw("service_scopes <> '[]'::jsonb") + .first(); + if ( + configured || + approved || + pending || + (await knex("public.asmblyr_policy_oauth_apps").first()) + ) { + throw new Error("Cannot discard configured application policies"); + } + await knex.schema.withSchema("public").dropTable("asmblyr_policy_oauth_apps"); + for (const name of ["asmblyr_oauth_consents", "asmblyr_oauth_grants"]) { + await knex.schema + .withSchema("public") + .alterTable(name, (table) => table.dropColumn("service_scopes")); + } + await knex.schema + .withSchema("public") + .alterTable("asmblyr_oauth_apps", (table) => { + table.dropColumn("policy_managed"); + table.dropColumn("scope_labels"); + }); +}; diff --git a/apps/core/migrations/20261007040000_system_user_relations.cjs b/apps/core/migrations/20261007040000_system_user_relations.cjs new file mode 100644 index 0000000..7033a59 --- /dev/null +++ b/apps/core/migrations/20261007040000_system_user_relations.cjs @@ -0,0 +1,20 @@ +exports.up = async (knex) => { + await knex.schema.withSchema("public").alterTable("asmblyr_relations", (table) => { + table.string("target_collection", 63).nullable().alter(); + table.string("target_system", 32).nullable(); + }); + await knex.raw(`ALTER TABLE public.asmblyr_relations ADD CONSTRAINT asmblyr_relation_target_check + CHECK ((target_collection IS NOT NULL AND target_system IS NULL) + OR (target_collection IS NULL AND target_system IS NOT NULL AND target_system = 'users'))`); +}; + +exports.down = async (knex) => { + await knex.raw("LOCK TABLE public.asmblyr_relations IN ACCESS EXCLUSIVE MODE"); + const relation = await knex("public.asmblyr_relations").whereNotNull("target_system").first(); + if (relation) throw new Error("Remove system user relations before rolling back"); + await knex.raw("ALTER TABLE public.asmblyr_relations DROP CONSTRAINT asmblyr_relation_target_check"); + await knex.schema.withSchema("public").alterTable("asmblyr_relations", (table) => { + table.dropColumn("target_system"); + table.string("target_collection", 63).notNullable().alter(); + }); +}; diff --git a/apps/core/src/auth/user-references.ts b/apps/core/src/auth/user-references.ts new file mode 100644 index 0000000..1915393 --- /dev/null +++ b/apps/core/src/auth/user-references.ts @@ -0,0 +1,91 @@ +import type { Knex } from "knex"; +import type { Principal } from "./principal.js"; +import { AccessDeniedError } from "../permissions/access.js"; +import { effectiveSettingsAccess } from "../settings/access.js"; +import { ItemError, parseItemId } from "../items/validation.js"; +import { objectInput } from "../shared/input.js"; + +export async function requireUserReferenceRead( + db: Knex, + principal: Principal, +): Promise { + if (principal.kind !== "user") throw new AccessDeniedError(); + if ( + !principal.superuser && + !(await effectiveSettingsAccess(db, principal.id)).sections.includes( + "users", + ) + ) { + throw new AccessDeniedError(); + } +} + +/** A bounded projection for pickers; never expose credentials or arbitrary user columns. */ +export async function listUserReferences( + db: Knex, + principal: Principal, + input: unknown, +) { + await requireUserReferenceRead(db, principal); + const query = objectInput(input, ["q", "page", "limit", "ids"]); + const page = query.page === undefined ? 1 : Number(query.page); + const limit = query.limit === undefined ? 25 : Number(query.limit); + if ( + !Number.isInteger(page) || + page < 1 || + page > 10000 || + !Number.isInteger(limit) || + limit < 1 || + limit > 100 || + (query.q !== undefined && + (typeof query.q !== "string" || query.q.length > 200)) || + (query.ids !== undefined && typeof query.ids !== "string") + ) { + throw new ItemError("Invalid user reference query", 400); + } + const ids = typeof query.ids === "string" ? query.ids.split(",") : undefined; + if (ids && (ids.length > 100 || !ids.length)) + throw new ItemError("Too many user IDs", 400); + ids?.forEach((id) => parseItemId(id, "uuid")); + const rows = db("public.asmblyr_users"); + // Existing disabled references can still be identified, but cannot be selected anew. + if (ids) rows.whereIn("id", ids); + else rows.where("status", "active"); + if (typeof query.q === "string" && query.q.trim()) { + const search = `%${query.q.trim().replace(/[\\%_]/g, "\\$&")}%`; + rows.where((builder) => { + for (const column of ["email", "display_name", "first_name", "last_name"]) + builder.orWhereILike(column, search); + }); + } + const [users, count] = await Promise.all([ + rows + .clone() + .select( + "id", + db.raw( + "concat_ws(' · ', coalesce(nullif(btrim(display_name), ''), nullif(btrim(concat_ws(' ', first_name, last_name)), '')), email) as label", + ), + ) + .orderBy("label") + .orderBy("id") + .limit(limit) + .offset((page - 1) * limit), + rows.clone().count<{ total: string }>("* as total").first(), + ]); + const data = users.map((user) => ({ + id: user.id as string, + label: user.label as string, + })); + return { + data, + labels: Object.fromEntries(data.map((user) => [user.id, user.label])), + page: { + number: page, + size: limit, + total: count?.total ?? "0", + sort: "label", + direction: "asc" as const, + }, + }; +} diff --git a/apps/core/src/auth/user-routes.ts b/apps/core/src/auth/user-routes.ts index 9364225..62c6f53 100644 --- a/apps/core/src/auth/user-routes.ts +++ b/apps/core/src/auth/user-routes.ts @@ -12,6 +12,8 @@ import { replaceUserDelegation } from "../policies/delegation-repository.js"; import { parsePolicyIds } from "../policies/assignment-validation.js"; import { createRecoveryLink } from "./link-login.js"; import { authenticateAccess } from "./tokens.js"; +import { authenticatePrincipal } from "./principal.js"; +import { listUserReferences } from "./user-references.js"; export function registerUserRoutes( app: FastifyInstance, @@ -30,6 +32,16 @@ export function registerUserRoutes( requireSettingsSection(db(), request, "users"), }; + app.get("/users/references", async (request, reply) => { + const principal = await authenticatePrincipal( + db(), + request.headers.authorization, + ); + return reply + .header("Cache-Control", "no-store") + .send(await listUserReferences(db(), principal, request.query)); + }); + const reader = { preHandler: (request: Parameters[1]) => requireSettingsRead(db(), request, "users", "policies"), diff --git a/apps/core/src/collections/catalog-repository.ts b/apps/core/src/collections/catalog-repository.ts index 721e312..0319aa4 100644 --- a/apps/core/src/collections/catalog-repository.ts +++ b/apps/core/src/collections/catalog-repository.ts @@ -73,9 +73,9 @@ export async function listCollections(database: Knex): Promise { WHERE ns.nspname = 'public' AND idx.relname = 'asmblyr_search_' || substr(md5(m.name || ':' || c.column_name), 1, 24) AND ix.indisvalid) AS search_indexed, - r.target_collection AS relation_target, - target.primary_key_name AS relation_key_name, - target.primary_key_type AS relation_key_type, r.on_delete, + CASE WHEN r.target_system = 'users' THEN '@users' ELSE r.target_collection END AS relation_target, + CASE WHEN r.target_system = 'users' THEN 'id' ELSE target.primary_key_name END AS relation_key_name, + CASE WHEN r.target_system = 'users' THEN 'uuid' ELSE target.primary_key_type END AS relation_key_type, r.on_delete, r.searchable AS relation_searchable FROM public.asmblyr_collections AS m LEFT JOIN public.asmblyr_columns AS c diff --git a/apps/core/src/collections/editable-field.ts b/apps/core/src/collections/editable-field.ts index f829c3d..e234a63 100644 --- a/apps/core/src/collections/editable-field.ts +++ b/apps/core/src/collections/editable-field.ts @@ -14,6 +14,7 @@ interface EditableFieldRow { default_value: JsonValue; presentation: FieldPresentation | null; relation_key_type: PrimaryKeyType | null; + target_system: "users" | null; } export async function readEditableField( @@ -24,7 +25,8 @@ export async function readEditableField( const result = await database.raw<{ rows: EditableFieldRow[] }>( ` SELECT c.is_nullable, c.data_type, c.character_maximum_length, fm.semantic_type, fm.default_value, fm.presentation, - COALESCE(fm.required, FALSE) AS required, target.primary_key_type AS relation_key_type + COALESCE(fm.required, FALSE) AS required, r.target_system, + CASE WHEN r.target_system = 'users' THEN 'uuid' ELSE target.primary_key_type END AS relation_key_type FROM public.asmblyr_columns AS c LEFT JOIN public.asmblyr_field_metadata AS fm ON fm.collection_name = c.table_name AND fm.field_name = c.column_name diff --git a/apps/core/src/collections/field-behavior-validation.ts b/apps/core/src/collections/field-behavior-validation.ts index 2bd3789..b16d08a 100644 --- a/apps/core/src/collections/field-behavior-validation.ts +++ b/apps/core/src/collections/field-behavior-validation.ts @@ -19,6 +19,14 @@ export async function validateFieldBehavior( const source = catalog.find((c) => c.name === collection)!; const current = source.fields.find((f) => f.name === field)!; const rules = presentation.rules; + if ( + current.relation?.collection === "@users" && + (presentation.relationFilter || rules?.computed) + ) { + throw new CollectionInputError( + "System user references do not support choice filters or computed values", + ); + } for (const rule of rules?.requiredWhen?.rules ?? []) { const dependency = source.fields.find((f) => f.name === rule.field); if ( diff --git a/apps/core/src/collections/field-default.ts b/apps/core/src/collections/field-default.ts index 1f923c9..0d86b77 100644 --- a/apps/core/src/collections/field-default.ts +++ b/apps/core/src/collections/field-default.ts @@ -27,6 +27,11 @@ export function normalizeFieldDefault( ? current.default_value : update.defaultValue; if (value === null) return null; + if (current.target_system) { + throw new CollectionInputError( + "System user relations do not support defaults", + ); + } if (current.presentation?.sensitive) throw new CollectionInputError("Sensitive fields do not support defaults"); diff --git a/apps/core/src/collections/relation-input.ts b/apps/core/src/collections/relation-input.ts index e59630d..51ad470 100644 --- a/apps/core/src/collections/relation-input.ts +++ b/apps/core/src/collections/relation-input.ts @@ -98,9 +98,28 @@ export function parseCreateRelation(value: unknown): CreateRelationInput { } const input = value as Record; const kind = input.kind ?? "m2o"; + const systemUser = input.targetCollection === "@users"; + if (systemUser && (kind !== "m2o" || input.reverseField)) { + throw new CollectionInputError( + "System users support M2O without a reverse field", + ); + } + if ( + systemUser && + (input.defaultValue !== undefined || + ![undefined, "restrict", "setNull"].includes( + input.onDelete as string | undefined, + )) + ) { + throw new CollectionInputError( + "System user relations support RESTRICT or SET NULL without defaults", + ); + } const common = { name: parseMutableFieldName(input.name), - targetCollection: parseMutableCollectionName(input.targetCollection), + targetCollection: systemUser + ? "@users" + : parseMutableCollectionName(input.targetCollection), }; const keys = kind === "m2o" diff --git a/apps/core/src/collections/relation-search.ts b/apps/core/src/collections/relation-search.ts index 61c82da..118b7e7 100644 --- a/apps/core/src/collections/relation-search.ts +++ b/apps/core/src/collections/relation-search.ts @@ -36,6 +36,17 @@ export async function updateRelationSearch( ? parseSearchPriority((body as Record).searchPriority) : undefined; return database.transaction(async (transaction) => { + const system = await transaction("public.asmblyr_relations") + .where({ + source_collection: name, + source_field: relationField, + target_system: "users", + }) + .first("source_field"); + if (system && searchable) + throw new CollectionInputError( + "System user references do not support related full-text search", + ); const physical = await transaction("asmblyr_relations") .withSchema("public") .where({ source_collection: name, source_field: relationField }) diff --git a/apps/core/src/collections/relations.ts b/apps/core/src/collections/relations.ts index 9ec359e..382aa13 100644 --- a/apps/core/src/collections/relations.ts +++ b/apps/core/src/collections/relations.ts @@ -44,14 +44,17 @@ export async function createForeignKey( ): Promise { const sourceSettings = await findCollectionSettings(transaction, source); if (!sourceSettings) throw new CollectionNotFoundError(source); - const targetSettings = await findCollectionSettings( - transaction, - input.targetCollection, - ); + const systemUser = input.targetCollection === "@users"; + const targetSettings = systemUser + ? { + sourceKind: "table" as const, + primaryKey: { name: "id", type: "uuid" as const }, + } + : await findCollectionSettings(transaction, input.targetCollection); if (!targetSettings) throw new CollectionNotFoundError(input.targetCollection); assertCollectionWritable(sourceSettings); - assertCollectionWritable(targetSettings); + if (!systemUser) assertCollectionWritable(targetSettings); if (isManagedColumn(sourceSettings, input.name)) { throw new CollectionFieldConflictError( `Field name is managed: ${input.name}`, @@ -99,7 +102,7 @@ export async function createForeignKey( `public.${source}`, `asmblyr_relation_fk_${digest}`, input.name, - `public.${input.targetCollection}`, + systemUser ? "public.asmblyr_users" : `public.${input.targetCollection}`, targetSettings.primaryKey.name, ], ); @@ -108,12 +111,15 @@ export async function createForeignKey( `public.${source}`, input.name, ]); - await transaction("asmblyr_relations").withSchema("public").insert({ - source_collection: source, - source_field: input.name, - target_collection: input.targetCollection, - on_delete: input.onDelete, - }); + await transaction("asmblyr_relations") + .withSchema("public") + .insert({ + source_collection: source, + source_field: input.name, + target_collection: systemUser ? null : input.targetCollection, + target_system: systemUser ? "users" : null, + on_delete: input.onDelete, + }); if (input.required || input.defaultValue !== undefined) { await transaction("asmblyr_field_metadata") .withSchema("public") diff --git a/apps/core/src/items/field-rule-service.ts b/apps/core/src/items/field-rule-service.ts index 06acb3e..3ad584b 100644 --- a/apps/core/src/items/field-rule-service.ts +++ b/apps/core/src/items/field-rule-service.ts @@ -13,6 +13,7 @@ import { requireGrant, AccessDeniedError } from "../permissions/access.js"; import { collectionSchema } from "./schema-repository.js"; import { listCollections } from "../collections/catalog-repository.js"; import { itemReadQuery } from "./read-query.js"; +import { validateUserReferenceWrites } from "./user-reference-writes.js"; /** Evaluate invariants on the final row inside the ordinary mutation transaction. */ export async function applyFieldRules( @@ -28,6 +29,13 @@ export async function applyFieldRules( (f) => f.presentation?.rules || f.presentation?.relationFilter, ); if (!configured.length) { + await validateUserReferenceWrites( + db, + fields, + input, + before, + context.access, + ); return input; } const values = { ...input }; @@ -216,5 +224,6 @@ export async function applyFieldRules( ); } } + await validateUserReferenceWrites(db, fields, values, before, context.access); return values; } diff --git a/apps/core/src/items/record-draft.ts b/apps/core/src/items/record-draft.ts index 5ac032c..f583466 100644 --- a/apps/core/src/items/record-draft.ts +++ b/apps/core/src/items/record-draft.ts @@ -41,6 +41,12 @@ export async function commitRecordDraft( const definition = schema.fields.get(field); if (!definition?.relation) throw new ItemError("Unknown reference field", 400); + if (definition.relation.collection === "@users") { + throw new ItemError( + "System users must be selected by ID, not edited in a record draft", + 400, + ); + } if (readable && !fieldGranted(readable, field)) throw new AccessDeniedError(); const relatedId = await save(definition.relation.collection, reference); diff --git a/apps/core/src/items/related.ts b/apps/core/src/items/related.ts index e51158c..54bade6 100644 --- a/apps/core/src/items/related.ts +++ b/apps/core/src/items/related.ts @@ -28,6 +28,7 @@ export async function requireRelatedRead( const relations = await database("asmblyr_relations") .withSchema("public") .where({ source_collection: source }) + .whereNull("target_system") .whereIn("source_field", fields) .select("target_collection", "source_field"); for (const relation of relations) { diff --git a/apps/core/src/items/schema-repository.ts b/apps/core/src/items/schema-repository.ts index ad26717..7e1a86c 100644 --- a/apps/core/src/items/schema-repository.ts +++ b/apps/core/src/items/schema-repository.ts @@ -41,8 +41,9 @@ export async function collectionSchema(database: Knex, name: string) { const result = await database.raw<{ rows: ColumnRow[] }>( ` SELECT c.column_name, c.data_type, c.character_maximum_length, c.is_nullable, fm.semantic_type, fm.required, - fm.default_value, fm.searchable, fm.search_priority, fm.presentation, r.target_collection AS relation_target, - target.primary_key_type AS relation_key_type + fm.default_value, fm.searchable, fm.search_priority, fm.presentation, + CASE WHEN r.target_system = 'users' THEN '@users' ELSE r.target_collection END AS relation_target, + CASE WHEN r.target_system = 'users' THEN 'uuid' ELSE target.primary_key_type END AS relation_key_type FROM public.asmblyr_columns AS c LEFT JOIN public.asmblyr_field_metadata AS fm ON fm.collection_name = ? AND fm.field_name = c.column_name diff --git a/apps/core/src/items/user-reference-writes.ts b/apps/core/src/items/user-reference-writes.ts new file mode 100644 index 0000000..61b5fdc --- /dev/null +++ b/apps/core/src/items/user-reference-writes.ts @@ -0,0 +1,42 @@ +import type { Knex } from "knex"; +import type { ItemField } from "./types.js"; +import type { Access } from "../permissions/access.js"; +import { AccessDeniedError } from "../permissions/access.js"; +import { requireUserReferenceRead } from "../auth/user-references.js"; +import { ItemError, parseItemId } from "./validation.js"; + +export async function validateUserReferenceWrites( + db: Knex.Transaction, + fields: Map, + values: Record, + before: Record | null, + access?: Access, +): Promise { + const ids = [ + ...new Set( + Object.entries(values) + .filter( + ([name, value]) => + value != null && + value !== before?.[name] && + fields.get(name)?.relation?.collection === "@users", + ) + .map(([, value]) => parseItemId(String(value), "uuid")), + ), + ]; + if (!ids.length) { + return; + } + if (!access) { + throw new AccessDeniedError(); + } + await requireUserReferenceRead(db, access.principal); + const users = await db("public.asmblyr_users") + .whereIn("id", ids) + .where("status", "active") + .select("id") + .forShare(); + if (users.length !== ids.length) { + throw new ItemError("Related user is unavailable", 409); + } +} diff --git a/apps/core/src/oauth/applications.ts b/apps/core/src/oauth/applications.ts index 898d59e..484ba28 100644 --- a/apps/core/src/oauth/applications.ts +++ b/apps/core/src/oauth/applications.ts @@ -6,6 +6,7 @@ import { AuthInputError } from "../auth/validation.js"; import type { ApplicationInput } from "./input.js"; import type { OAuthCipher } from "./crypto.js"; import { matchesEmailDomain, type ApplicationAccessMode } from "./access.js"; +import { effectiveApplicationGrant } from "./policy-access.js"; interface ApplicationRow { id: string; @@ -18,6 +19,8 @@ interface ApplicationRow { redirect_uris: string[]; audience: string; scopes: string[]; + policy_managed: boolean; + scope_labels: Record; secret: string | null; created_at: Date; } @@ -53,6 +56,8 @@ export class OAuthApplications { redirectUris: row.redirect_uris, audience: row.audience, scopes: row.scopes, + policyManaged: row.policy_managed, + scopeLabels: row.scope_labels, createdAt: row.created_at, userIds: memberships .filter((membership) => membership.app_id === row.id) @@ -85,13 +90,19 @@ export class OAuthApplications { email_domains: string[]; email: string; selected_user: string | null; + policy_managed: boolean; }>( "app.access_mode", "app.email_domains", "usr.email", "membership.user_id as selected_user", + "app.policy_managed", ); if (!result) return false; + if (result.policy_managed) { + return (await effectiveApplicationGrant(database, clientId, userId)) + .allowed; + } if (result.access_mode === "all") return true; if (result.access_mode === "selected") return result.selected_user !== null; if (result.access_mode === "domains") { @@ -103,6 +114,25 @@ export class OAuthApplications { return false; } + async serviceScopes( + clientId: string, + userId: string, + database: Knex = this.db, + ): Promise { + const app = await database("public.asmblyr_oauth_apps") + .where({ id: clientId }) + .first(); + if ( + !app?.policy_managed || + !app.enabled || + !(await this.allowed(clientId, userId, database)) + ) { + return []; + } + const grant = await effectiveApplicationGrant(database, clientId, userId); + return grant.scopes.filter((scope) => app.scopes.includes(scope)); + } + async client(id: string): Promise { const row = await this.row(id); if (!row?.enabled) return undefined; @@ -155,12 +185,38 @@ export class OAuthApplications { redirect_uris: JSON.stringify(input.redirectUris), audience: input.audience, scopes: JSON.stringify(input.scopes), + policy_managed: + input.policyManaged ?? previous?.policy_managed ?? false, + scope_labels: JSON.stringify( + input.scopeLabels ?? + Object.fromEntries( + Object.entries(previous?.scope_labels ?? {}).filter(([scope]) => + input.scopes.includes(scope), + ), + ), + ), updated_at: trx.fn.now(), }; if (id) { await trx("public.asmblyr_oauth_apps").where({ id }).update(values); + // Removing a catalog permission must not resurrect old policy grants if it is added again. + const grants = await trx("public.asmblyr_policy_oauth_apps") + .where({ app_id: id }) + .select("policy_id", "scopes"); + for (const grant of grants) { + await trx("public.asmblyr_policy_oauth_apps") + .where({ app_id: id, policy_id: grant.policy_id }) + .update({ + scopes: JSON.stringify( + (grant.scopes as string[]).filter((scope) => + input.scopes.includes(scope), + ), + ), + }); + } const recipientChanged = previous!.audience !== input.audience || + previous!.policy_managed !== values.policy_managed || input.redirectUris.some( (uri) => !previous!.redirect_uris.includes(uri), ); diff --git a/apps/core/src/oauth/consents.ts b/apps/core/src/oauth/consents.ts index 3aa30ec..f67bdfc 100644 --- a/apps/core/src/oauth/consents.ts +++ b/apps/core/src/oauth/consents.ts @@ -12,6 +12,7 @@ interface ConsentRow { scopes: string[]; approved_at: Date; last_used_at: Date | null; + service_scopes: string[]; } export interface ConsentRequest { @@ -41,12 +42,14 @@ function covers( row: ConsentRow | undefined, audience: string, request: ConsentRequest, + serviceScopes: string[] = [], ): boolean { return Boolean( row && !request.forceConsent && row.audience === audience && - request.scopes.every((scope) => row.scopes.includes(scope)), + request.scopes.every((scope) => row.scopes.includes(scope)) && + serviceScopes.every((scope) => row.service_scopes.includes(scope)), ); } @@ -66,7 +69,12 @@ export class OAuthConsents { const row = await this.db("public.asmblyr_oauth_consents") .where({ app_id: appId, user_id: userId }) .first(); - return covers(row, app.audience, request); + return covers( + row, + app.audience, + request, + await this.apps!.serviceScopes(appId, userId), + ); } async authorize( @@ -74,22 +82,53 @@ export class OAuthConsents { userId: string, request: ConsentRequest, reuse: boolean, + expectedServiceScopes?: string[], ) { return this.db.transaction(async (trx) => { // Same lock order as application edits and personal revocation. const app = await trx("public.asmblyr_oauth_apps") .where({ id: appId }) .forUpdate() - .first<{ id: string; audience: string }>("id", "audience"); + .first<{ + id: string; + audience: string; + policy_managed: boolean; + }>("id", "audience", "policy_managed"); if (!app || !(await this.apps?.allowed(appId, userId, trx))) { throw Object.assign(new Error("Application access denied"), { statusCode: 403, }); } + if ( + app.policy_managed && + request.scopes.some( + (scope) => !["openid", "profile", "email"].includes(scope), + ) + ) { + throw Object.assign( + new Error("Policy-managed applications request profile scopes only"), + { statusCode: 400 }, + ); + } + const serviceScopes = await this.apps!.serviceScopes(appId, userId, trx); + if ( + app.policy_managed && + !reuse && + (expectedServiceScopes === undefined || + JSON.stringify([...new Set(expectedServiceScopes)].sort()) !== + JSON.stringify(serviceScopes)) + ) { + throw Object.assign( + new Error( + "Разрешения изменились. Откройте вход в приложение заново.", + ), + { statusCode: 409 }, + ); + } let row = await trx("public.asmblyr_oauth_consents") .where({ app_id: appId, user_id: userId }) .first(); - if (reuse && !covers(row, app.audience, request)) { + if (reuse && !covers(row, app.audience, request, serviceScopes)) { throw Object.assign( new Error("Согласие изменилось. Подтвердите доступ заново."), { @@ -110,6 +149,7 @@ export class OAuthConsents { audience: app.audience, scopes: trx.raw("?::jsonb", [JSON.stringify(scopes)]), approved_at: trx.fn.now(), + service_scopes: trx.raw("?::jsonb", [JSON.stringify(serviceScopes)]), }; [row] = await trx("public.asmblyr_oauth_consents") .insert(values) @@ -125,6 +165,7 @@ export class OAuthConsents { grant_hash: digest(grantId), consent_id: row!.id, expires_at: new Date(Date.now() + 600_000), + service_scopes: JSON.stringify(serviceScopes), }); return { grantId, audience: app.audience }; }); @@ -147,6 +188,7 @@ export class OAuthConsents { enabled: row.enabled, audience: row.audience, scopes: row.scopes, + serviceScopes: row.service_scopes, approvedAt: row.approved_at, lastUsedAt: row.last_used_at, })); diff --git a/apps/core/src/oauth/grant-bindings.ts b/apps/core/src/oauth/grant-bindings.ts index 3c2dab9..6ea5616 100644 --- a/apps/core/src/oauth/grant-bindings.ts +++ b/apps/core/src/oauth/grant-bindings.ts @@ -27,7 +27,8 @@ export async function findGrantBinding(db: Knex, hash: string) { consent_id: string; app_id: string; user_id: string; - }>("grant.consent_id", "consent.app_id", "consent.user_id"); + service_scopes: string[]; + }>("grant.consent_id", "consent.app_id", "consent.user_id", "grant.service_scopes"); } export async function requireGrantBinding( diff --git a/apps/core/src/oauth/input.ts b/apps/core/src/oauth/input.ts index 4fc8f5a..b1df9bd 100644 --- a/apps/core/src/oauth/input.ts +++ b/apps/core/src/oauth/input.ts @@ -10,6 +10,8 @@ export interface ApplicationInput extends ApplicationAccess { userIds: string[]; audience: string; scopes: string[]; + policyManaged?: boolean; + scopeLabels?: Record; } function text( @@ -49,6 +51,8 @@ export function parseApplication(value: unknown): ApplicationInput { "emailDomains", "audience", "scopes", + "policyManaged", + "scopeLabels", ]; if (Object.keys(input).some((key) => !allowed.includes(key))) throw new AuthInputError("Unknown application setting"); @@ -106,6 +110,30 @@ export function parseApplication(value: unknown): ApplicationInput { throw new AuthInputError("Invalid service scope"); if (scopes.length && !audience) throw new AuthInputError("Service scopes require an audience"); + if ( + input.policyManaged !== undefined && + typeof input.policyManaged !== "boolean" + ) { + throw new AuthInputError("Invalid policy management setting"); + } + let scopeLabels: Record | undefined; + if (input.scopeLabels !== undefined) { + if ( + !input.scopeLabels || + typeof input.scopeLabels !== "object" || + Array.isArray(input.scopeLabels) + ) { + throw new AuthInputError("Invalid permission labels"); + } + scopeLabels = Object.fromEntries( + Object.entries(input.scopeLabels).map(([scope, label]) => { + if (!scopes.includes(scope)) { + throw new AuthInputError("Unknown permission label"); + } + return [scope, text(label, "permission label", 120)]; + }), + ); + } const access = parseApplicationAccess(input); return { ...access, @@ -117,5 +145,7 @@ export function parseApplication(value: unknown): ApplicationInput { userIds: access.accessMode === "all" ? [] : userIds, audience, scopes, + policyManaged: input.policyManaged as boolean | undefined, + scopeLabels, }; } diff --git a/apps/core/src/oauth/interactions.ts b/apps/core/src/oauth/interactions.ts index e3afea7..887a7bb 100644 --- a/apps/core/src/oauth/interactions.ts +++ b/apps/core/src/oauth/interactions.ts @@ -35,6 +35,12 @@ export function registerOAuthInteractions( redirectUri: details.params.redirect_uri, scopes: details.params.scope, allowed: await apps.allowed(client.id, user.id), + servicePermissions: (await apps.serviceScopes(client.id, user.id)).map( + (scope) => ({ + scope, + name: client.scope_labels[scope] ?? scope, + }), + ), canReuseConsent: await consents.canReuse( client.id, user.id, @@ -63,6 +69,19 @@ export function registerOAuthInteractions( }, ); } + const expectedServiceScopes = input.serviceScopes; + if ( + expectedServiceScopes !== undefined && + (!Array.isArray(expectedServiceScopes) || + expectedServiceScopes.length > 30 || + expectedServiceScopes.some( + (scope) => typeof scope !== "string" || scope.length > 160, + )) + ) { + throw Object.assign(new Error("Invalid application permissions"), { + statusCode: 400, + }); + } const details = await provider.interactionDetails(request.raw, reply.raw); if (details.uid !== request.params.uid) throw Object.assign(new Error("Invalid interaction"), { @@ -80,6 +99,7 @@ export function registerOAuthInteractions( user.id, request, input.reuse === true, + expectedServiceScopes as string[] | undefined, ); const scopes = request.scopes.join(" "); const grant = new provider.Grant({ accountId: user.id, clientId }); diff --git a/apps/core/src/oauth/policy-access.ts b/apps/core/src/oauth/policy-access.ts new file mode 100644 index 0000000..5144592 --- /dev/null +++ b/apps/core/src/oauth/policy-access.ts @@ -0,0 +1,100 @@ +import type { Knex } from "knex"; + +export interface ApplicationGrant { + appId: string; + scopes: string[]; +} + +export async function policyApplicationCatalog(db: Knex) { + return db("public.asmblyr_oauth_apps") + .where({ policy_managed: true }) + .orderBy("name") + .select( + "id", + "name", + "enabled", + "audience", + "scopes", + "scope_labels as scopeLabels", + ); +} + +export async function policyApplicationGrants( + db: Knex, + policyId: string, +): Promise { + return db("public.asmblyr_policy_oauth_apps") + .where({ policy_id: policyId }) + .orderBy("app_id") + .select("app_id as appId", "scopes"); +} + +export async function effectiveApplicationGrant( + db: Knex, + appId: string, + userId: string, +) { + const grants = await db("public.asmblyr_policy_oauth_apps as grant") + .join( + "public.asmblyr_user_policies as assignment", + "assignment.policy_id", + "grant.policy_id", + ) + .where({ "grant.app_id": appId, "assignment.user_id": userId }) + .select<{ scopes: string[] }[]>("grant.scopes"); + return { + allowed: grants.length > 0, + scopes: [...new Set(grants.flatMap((grant) => grant.scopes))].sort(), + }; +} + +export async function replacePolicyApplications( + db: Knex.Transaction, + policyId: string, + grants: ApplicationGrant[], +) { + const previous = await policyApplicationGrants(db, policyId); + const apps = await db("public.asmblyr_oauth_apps") + .whereIn("id", grants.map((grant) => grant.appId).sort()) + .orderBy("id") + .forShare() + .select<{ id: string; policy_managed: boolean; scopes: string[] }[]>( + "id", + "policy_managed", + "scopes", + ); + for (const grant of grants) { + const app = apps.find((app) => app.id === grant.appId); + const unchangedInactive = + app && + !app.policy_managed && + previous.some( + (entry) => + entry.appId === app.id && + JSON.stringify([...entry.scopes].sort()) === + JSON.stringify([...grant.scopes].sort()), + ); + if ( + !app || + (!app.policy_managed && !unchangedInactive) || + grant.scopes.some((scope) => !app.scopes.includes(scope)) + ) { + throw Object.assign( + new Error("Unknown application or application permission"), + { statusCode: 400 }, + ); + } + } + await db("public.asmblyr_policy_oauth_apps") + .where({ policy_id: policyId }) + .delete(); + if (grants.length) { + await db("public.asmblyr_policy_oauth_apps").insert( + grants.map((grant) => ({ + policy_id: policyId, + app_id: grant.appId, + scopes: JSON.stringify(grant.scopes), + })), + ); + } +} diff --git a/apps/core/src/oauth/provider.ts b/apps/core/src/oauth/provider.ts index 38e469f..9a2d078 100644 --- a/apps/core/src/oauth/provider.ts +++ b/apps/core/src/oauth/provider.ts @@ -4,6 +4,7 @@ import type { OAuthConfig } from "./config.js"; import { OAuthCipher } from "./crypto.js"; import { OAuthApplications } from "./applications.js"; import { oauthAdapter } from "./adapter.js"; +import { applicationTokenPermissions } from "./token-permissions.js"; export function createOAuthProvider(db: Knex, config: OAuthConfig) { const applications = new OAuthApplications( @@ -64,7 +65,12 @@ export function createOAuthProvider(db: Knex, config: OAuthConfig) { resource !== `urn:asmblyr:application:${app.id}` ) throw new errors.InvalidTarget(); - const scopes = ["openid", "profile", "email", ...app.scopes]; + const scopes = [ + "openid", + "profile", + "email", + ...(app.policy_managed ? [] : app.scopes), + ]; const requested = String(ctx.oidc.params?.scope ?? "") .split(" ") .filter(Boolean); @@ -94,9 +100,15 @@ export function createOAuthProvider(db: Knex, config: OAuthConfig) { ) throw new errors.AccessDenied(); const account = ctx.oidc.account; - return account - ? account.claims("access_token", "openid profile email", {}, []) - : undefined; + const profile = account + ? await account.claims("access_token", "openid profile email", {}, []) + : {}; + const permissions = await applicationTokenPermissions( + db, + applications, + token, + ); + return { ...profile, ...permissions }; }, interactions: { policy, diff --git a/apps/core/src/oauth/token-permissions.ts b/apps/core/src/oauth/token-permissions.ts new file mode 100644 index 0000000..200c788 --- /dev/null +++ b/apps/core/src/oauth/token-permissions.ts @@ -0,0 +1,36 @@ +import type { Knex } from "knex"; +import { errors } from "oidc-provider"; +import type { OAuthApplications } from "./applications.js"; +import { findGrantBinding } from "./grant-bindings.js"; +import { digest } from "./crypto.js"; + +export async function applicationTokenPermissions( + db: Knex, + apps: OAuthApplications, + token: { clientId?: string; accountId?: string; grantId?: string }, +): Promise> { + const { clientId, accountId, grantId } = token; + if (!clientId || !accountId || !(await apps.allowed(clientId, accountId))) { + throw new errors.AccessDenied(); + } + const app = await apps.row(clientId); + if (!app?.policy_managed || !app.audience) { + return {}; + } + const binding = grantId + ? await findGrantBinding(db, digest(grantId)) + : undefined; + if ( + !binding || + binding.app_id !== clientId || + binding.user_id !== accountId + ) { + throw new errors.AccessDenied(); + } + const currentScopes = await apps.serviceScopes(clientId, accountId); + // A pending authorization must never gain permissions added after consent. + const roles = currentScopes.filter((scope) => + binding.service_scopes.includes(scope), + ); + return { resource_access: { [app.audience]: { roles } } }; +} diff --git a/apps/core/src/policies/configuration-permissions.ts b/apps/core/src/policies/configuration-permissions.ts new file mode 100644 index 0000000..c2c1e9e --- /dev/null +++ b/apps/core/src/policies/configuration-permissions.ts @@ -0,0 +1,242 @@ +import { + lockPermissionCollection, + validateSourcePermission, +} from "../permissions/collection-lock.js"; +import { + parseRowFilter, + validateRowFilter, +} from "../permissions/row-filter.js"; +import type { PermissionFilter } from "@asmblyr-collaborative/contracts"; +import type { CollectionPermissionInput } from "../permissions/validation.js"; +import type { Knex } from "knex"; +import { + PermissionInputError, + PermissionNotFoundError, + type PermissionAction, +} from "../permissions/validation.js"; + +interface CollectionRow { + id: string; + name: string; +} +interface PermissionRow { + id: string; + collection_id: string; + action: PermissionAction; + fields: string[]; + row_filter: PermissionFilter | null; +} + +function fieldsKey(fields: string[]): string { + return [...fields].sort().join("\u0000"); +} + +function grantKey( + collectionId: string, + action: PermissionAction, + fields: string[], + rowFilter?: PermissionFilter | null, +): string { + return `${collectionId}:${action}:${fieldsKey(fields)}:${JSON.stringify(parseRowFilter(rowFilter ?? null))}`; +} + +export async function writePermissions( + transaction: Knex.Transaction, + policyId: string, + input: { permissions: CollectionPermissionInput[] }, +): Promise { + const names = [ + ...new Set(input.permissions.map((entry) => entry.collection)), + ].sort(); + const collections = + names.length === 0 + ? [] + : await transaction("asmblyr_collections") + .withSchema("public") + .whereIn("name", names) + .select("id", "name"); + if (collections.length !== names.length) { + throw new PermissionNotFoundError(); + } + const byName = new Map( + collections.map((collection) => [collection.name, collection]), + ); + + for (const name of names) { + await lockPermissionCollection(transaction, name); + } + const columns = + names.length === 0 + ? [] + : await transaction("public.asmblyr_columns") + .where({ table_schema: "public" }) + .whereIn("table_name", names) + .select< + { table_name: string; column_name: string }[] + >("table_name", "column_name"); + const available = new Set( + columns.map((column) => `${column.table_name}:${column.column_name}`), + ); + const aliases = + names.length === 0 + ? [] + : await transaction("asmblyr_relation_aliases") + .withSchema("public") + .whereIn("collection_name", names) + .select< + { collection_name: string; field_name: string }[] + >("collection_name", "field_name"); + const readableAliases = new Set( + aliases.map((alias) => `${alias.collection_name}:${alias.field_name}`), + ); + for (const entry of input.permissions) { + await validateSourcePermission(transaction, entry.collection, entry.action); + await validateRowFilter(transaction, entry.collection, entry.rowFilter); + if ( + entry.fields.some( + (field) => + field !== "*" && + !available.has(`${entry.collection}:${field}`) && + (entry.action !== "read" || + !readableAliases.has(`${entry.collection}:${field}`)), + ) + ) { + throw new PermissionInputError("Unknown permission field"); + } + } + + const linked = await transaction("asmblyr_policy_permissions as link") + .withSchema("public") + .join( + "asmblyr_permissions as permission", + "permission.id", + "link.permission_id", + ) + .where("link.policy_id", policyId) + .whereNotNull("permission.collection_id") + .select< + PermissionRow[] + >("permission.id", "permission.collection_id", "permission.action", "permission.fields", "permission.row_filter"); + const catalog = + collections.length === 0 + ? [] + : await transaction("asmblyr_permissions") + .withSchema("public") + .whereIn( + "collection_id", + collections.map((entry) => entry.id), + ) + .select( + "id", + "collection_id", + "action", + "fields", + "row_filter", + ); + const selected = new Set(); + + for (const entry of input.permissions) { + const collectionId = byName.get(entry.collection)!.id; + const key = grantKey( + collectionId, + entry.action, + entry.fields, + entry.rowFilter, + ); + let permission = + linked.find( + (candidate) => + grantKey( + candidate.collection_id, + candidate.action, + candidate.fields, + candidate.row_filter, + ) === key, + ) ?? + catalog.find( + (candidate) => + grantKey( + candidate.collection_id, + candidate.action, + candidate.fields, + candidate.row_filter, + ) === key, + ); + + if (!permission) { + const previous = linked.filter( + (candidate) => + candidate.collection_id === collectionId && + candidate.action === entry.action, + ); + if ( + previous.length === 1 && + input.permissions.filter( + (candidate) => + candidate.collection === entry.collection && + candidate.action === entry.action, + ).length === 1 + ) { + const existing = previous[0]; + await transaction("asmblyr_permissions") + .withSchema("public") + .where({ id: existing.id }) + .forUpdate() + .first("id"); + const count = await transaction("asmblyr_policy_permissions") + .withSchema("public") + .where({ permission_id: existing.id }) + .count<{ count: string }>("* as count") + .first(); + if (Number(count?.count) === 1) { + await transaction("asmblyr_permissions") + .withSchema("public") + .where({ id: existing.id }) + .update({ + fields: entry.fields, + row_filter: entry.rowFilter ?? null, + }); + permission = { + ...existing, + fields: entry.fields, + row_filter: entry.rowFilter ?? null, + }; + } + } + } + if (!permission) { + const [created] = await transaction("asmblyr_permissions") + .withSchema("public") + .insert({ + collection_id: collectionId, + action: entry.action, + fields: entry.fields, + row_filter: entry.rowFilter ?? null, + }) + .returning([ + "id", + "collection_id", + "action", + "fields", + "row_filter", + ]); + permission = created; + } + selected.add(permission.id); + if (!linked.some((candidate) => candidate.id === permission.id)) { + await transaction("asmblyr_policy_permissions") + .withSchema("public") + .insert({ policy_id: policyId, permission_id: permission.id }); + } + } + const obsolete = linked + .filter((permission) => !selected.has(permission.id)) + .map((permission) => permission.id); + if (obsolete.length > 0) { + await transaction("asmblyr_policy_permissions") + .withSchema("public") + .where({ policy_id: policyId }) + .whereIn("permission_id", obsolete) + .delete(); + } +} diff --git a/apps/core/src/policies/configuration-repository.ts b/apps/core/src/policies/configuration-repository.ts index 0039532..9e44b83 100644 --- a/apps/core/src/policies/configuration-repository.ts +++ b/apps/core/src/policies/configuration-repository.ts @@ -1,248 +1,10 @@ -import { - lockPermissionCollection, - validateSourcePermission, -} from "../permissions/collection-lock.js"; -import { - parseRowFilter, - validateRowFilter, -} from "../permissions/row-filter.js"; -import type { PermissionFilter } from "@asmblyr-collaborative/contracts"; -import { replaceSettingsPermissions } from "../permissions/settings-permissions.js"; -import type { CollectionPermissionInput } from "../permissions/validation.js"; import type { Knex } from "knex"; -import { - PermissionInputError, - PermissionNotFoundError, - type PermissionAction, -} from "../permissions/validation.js"; +import { PermissionNotFoundError } from "../permissions/validation.js"; +import { replaceSettingsPermissions } from "../permissions/settings-permissions.js"; +import { writePermissions } from "./configuration-permissions.js"; import type { PolicyConfigurationInput } from "./configuration-validation.js"; import { PolicyConflictError, PolicyNotFoundError } from "./validation.js"; - -interface CollectionRow { - id: string; - name: string; -} -interface PermissionRow { - id: string; - collection_id: string; - action: PermissionAction; - fields: string[]; - row_filter: PermissionFilter | null; -} - -function fieldsKey(fields: string[]): string { - return [...fields].sort().join("\u0000"); -} - -function grantKey( - collectionId: string, - action: PermissionAction, - fields: string[], - rowFilter?: PermissionFilter | null, -): string { - return `${collectionId}:${action}:${fieldsKey(fields)}:${JSON.stringify(parseRowFilter(rowFilter ?? null))}`; -} - -async function writePermissions( - transaction: Knex.Transaction, - policyId: string, - input: { permissions: CollectionPermissionInput[] }, -): Promise { - const names = [ - ...new Set(input.permissions.map((entry) => entry.collection)), - ].sort(); - const collections = - names.length === 0 - ? [] - : await transaction("asmblyr_collections") - .withSchema("public") - .whereIn("name", names) - .select("id", "name"); - if (collections.length !== names.length) { - throw new PermissionNotFoundError(); - } - const byName = new Map( - collections.map((collection) => [collection.name, collection]), - ); - - for (const name of names) { - await lockPermissionCollection(transaction, name); - } - const columns = - names.length === 0 - ? [] - : await transaction("public.asmblyr_columns") - .where({ table_schema: "public" }) - .whereIn("table_name", names) - .select< - { table_name: string; column_name: string }[] - >("table_name", "column_name"); - const available = new Set( - columns.map((column) => `${column.table_name}:${column.column_name}`), - ); - const aliases = - names.length === 0 - ? [] - : await transaction("asmblyr_relation_aliases") - .withSchema("public") - .whereIn("collection_name", names) - .select< - { collection_name: string; field_name: string }[] - >("collection_name", "field_name"); - const readableAliases = new Set( - aliases.map((alias) => `${alias.collection_name}:${alias.field_name}`), - ); - for (const entry of input.permissions) { - await validateSourcePermission(transaction, entry.collection, entry.action); - await validateRowFilter(transaction, entry.collection, entry.rowFilter); - if ( - entry.fields.some( - (field) => - field !== "*" && - !available.has(`${entry.collection}:${field}`) && - (entry.action !== "read" || - !readableAliases.has(`${entry.collection}:${field}`)), - ) - ) { - throw new PermissionInputError("Unknown permission field"); - } - } - - const linked = await transaction("asmblyr_policy_permissions as link") - .withSchema("public") - .join( - "asmblyr_permissions as permission", - "permission.id", - "link.permission_id", - ) - .where("link.policy_id", policyId) - .whereNotNull("permission.collection_id") - .select< - PermissionRow[] - >("permission.id", "permission.collection_id", "permission.action", "permission.fields", "permission.row_filter"); - const catalog = - collections.length === 0 - ? [] - : await transaction("asmblyr_permissions") - .withSchema("public") - .whereIn( - "collection_id", - collections.map((entry) => entry.id), - ) - .select( - "id", - "collection_id", - "action", - "fields", - "row_filter", - ); - const selected = new Set(); - - for (const entry of input.permissions) { - const collectionId = byName.get(entry.collection)!.id; - const key = grantKey( - collectionId, - entry.action, - entry.fields, - entry.rowFilter, - ); - let permission = - linked.find( - (candidate) => - grantKey( - candidate.collection_id, - candidate.action, - candidate.fields, - candidate.row_filter, - ) === key, - ) ?? - catalog.find( - (candidate) => - grantKey( - candidate.collection_id, - candidate.action, - candidate.fields, - candidate.row_filter, - ) === key, - ); - - if (!permission) { - const previous = linked.filter( - (candidate) => - candidate.collection_id === collectionId && - candidate.action === entry.action, - ); - if ( - previous.length === 1 && - input.permissions.filter( - (candidate) => - candidate.collection === entry.collection && - candidate.action === entry.action, - ).length === 1 - ) { - const existing = previous[0]; - await transaction("asmblyr_permissions") - .withSchema("public") - .where({ id: existing.id }) - .forUpdate() - .first("id"); - const count = await transaction("asmblyr_policy_permissions") - .withSchema("public") - .where({ permission_id: existing.id }) - .count<{ count: string }>("* as count") - .first(); - if (Number(count?.count) === 1) { - await transaction("asmblyr_permissions") - .withSchema("public") - .where({ id: existing.id }) - .update({ - fields: entry.fields, - row_filter: entry.rowFilter ?? null, - }); - permission = { - ...existing, - fields: entry.fields, - row_filter: entry.rowFilter ?? null, - }; - } - } - } - if (!permission) { - const [created] = await transaction("asmblyr_permissions") - .withSchema("public") - .insert({ - collection_id: collectionId, - action: entry.action, - fields: entry.fields, - row_filter: entry.rowFilter ?? null, - }) - .returning([ - "id", - "collection_id", - "action", - "fields", - "row_filter", - ]); - permission = created; - } - selected.add(permission.id); - if (!linked.some((candidate) => candidate.id === permission.id)) { - await transaction("asmblyr_policy_permissions") - .withSchema("public") - .insert({ policy_id: policyId, permission_id: permission.id }); - } - } - const obsolete = linked - .filter((permission) => !selected.has(permission.id)) - .map((permission) => permission.id); - if (obsolete.length > 0) { - await transaction("asmblyr_policy_permissions") - .withSchema("public") - .where({ policy_id: policyId }) - .whereIn("permission_id", obsolete) - .delete(); - } -} +import { replacePolicyApplications } from "../oauth/policy-access.js"; async function writeUsers( transaction: Knex.Transaction, @@ -310,6 +72,11 @@ export async function createPolicyConfiguration( input.permissions.filter((entry) => "section" in entry), ); await writeUsers(transaction, policy.id, input.userIds); + await replacePolicyApplications( + transaction, + policy.id, + input.applications ?? [], + ); return policy; }); } catch (error) { @@ -341,6 +108,9 @@ export async function updatePolicyConfiguration( input.permissions.filter((entry) => "section" in entry), ); await writeUsers(transaction, id, input.userIds); + if (input.applications !== undefined) { + await replacePolicyApplications(transaction, id, input.applications); + } const [updated] = await transaction("asmblyr_policies") .withSchema("public") .where({ id }) diff --git a/apps/core/src/policies/configuration-validation.ts b/apps/core/src/policies/configuration-validation.ts index 598be14..5443c06 100644 --- a/apps/core/src/policies/configuration-validation.ts +++ b/apps/core/src/policies/configuration-validation.ts @@ -3,11 +3,13 @@ import { type CreatePermissionInput, } from "../permissions/validation.js"; import { parseId, parseName, PolicyInputError } from "./validation.js"; +import type { ApplicationGrant } from "../oauth/policy-access.js"; export interface PolicyConfigurationInput { name: string; permissions: CreatePermissionInput[]; userIds: string[]; + applications?: ApplicationGrant[]; } export function hasPolicyConfiguration(body: unknown): boolean { @@ -15,7 +17,7 @@ export function hasPolicyConfiguration(body: unknown): boolean { body !== null && typeof body === "object" && !Array.isArray(body) && - ("permissions" in body || "userIds" in body) + ("permissions" in body || "userIds" in body || "applications" in body) ); } @@ -27,7 +29,10 @@ export function parsePolicyConfiguration( } const input = body as Record; if ( - Object.keys(input).length !== 3 || + Object.keys(input).some( + (key) => + !["name", "permissions", "userIds", "applications"].includes(key), + ) || !Object.hasOwn(input, "name") || !Object.hasOwn(input, "permissions") || !Object.hasOwn(input, "userIds") || @@ -66,5 +71,42 @@ export function parsePolicyConfiguration( if (new Set(userIds).size !== userIds.length) { throw new PolicyInputError("Duplicate user in policy"); } - return { name: parseName({ name: input.name }), permissions, userIds }; + let applications: ApplicationGrant[] | undefined; + if (input.applications !== undefined) { + if (!Array.isArray(input.applications) || input.applications.length > 100) { + throw new PolicyInputError("Invalid application permissions"); + } + applications = input.applications.map((entry: unknown) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new PolicyInputError("Invalid application permission"); + } + const grant = entry as Record; + if ( + Object.keys(grant).some((key) => !["appId", "scopes"].includes(key)) || + !Array.isArray(grant.scopes) || + grant.scopes.length > 30 || + grant.scopes.some( + (scope) => typeof scope !== "string" || scope.length > 160, + ) + ) { + throw new PolicyInputError("Invalid application permission"); + } + return { + appId: parseId(grant.appId), + scopes: [...new Set(grant.scopes as string[])].sort(), + }; + }); + if ( + new Set(applications.map((grant) => grant.appId)).size !== + applications.length + ) { + throw new PolicyInputError("Duplicate application in policy"); + } + } + return { + name: parseName({ name: input.name }), + permissions, + userIds, + applications, + }; } diff --git a/apps/core/src/policies/repository.ts b/apps/core/src/policies/repository.ts index a29695b..aed3754 100644 --- a/apps/core/src/policies/repository.ts +++ b/apps/core/src/policies/repository.ts @@ -1,6 +1,7 @@ import { listPermissions } from "../permissions/repository.js"; import type { Knex } from "knex"; import { PolicyConflictError, PolicyNotFoundError } from "./validation.js"; +import { policyApplicationGrants } from "../oauth/policy-access.js"; function translateConflict(error: unknown): never { if ( @@ -51,7 +52,12 @@ export async function getPolicy(database: Knex, id: string) { .where("assignment.policy_id", id) .select("usr.id", "usr.email") .orderBy("usr.email"); - return { ...policy, permissions, users }; + return { + ...policy, + permissions, + users, + applications: await policyApplicationGrants(database, id), + }; } export async function createPolicy(database: Knex, name: string) { diff --git a/apps/core/src/policies/routes.ts b/apps/core/src/policies/routes.ts index e3009f7..3b71efa 100644 --- a/apps/core/src/policies/routes.ts +++ b/apps/core/src/policies/routes.ts @@ -25,6 +25,7 @@ import { renamePolicy, } from "./repository.js"; import { parseId, parseName } from "./validation.js"; +import { policyApplicationCatalog } from "../oauth/policy-access.js"; interface PolicyParams { id: string; @@ -61,6 +62,9 @@ export function registerPolicyRoutes( app.get("/policies", reader, async () => ({ data: await listPolicies(db()), })); + app.get("/policies/applications", reader, async () => ({ + data: await policyApplicationCatalog(db()), + })); app.post("/policies", admin, async (request, reply) => reply.code(201).send({ data: hasPolicyConfiguration(request.body) diff --git a/apps/core/test/oauth-consents.integration.test.ts b/apps/core/test/oauth-consents.integration.test.ts index b0048bc..ac506d9 100644 --- a/apps/core/test/oauth-consents.integration.test.ts +++ b/apps/core/test/oauth-consents.integration.test.ts @@ -247,10 +247,15 @@ test("Consent migration preserves existing applications, never backfills approva up(db: Knex): Promise; down(db: Knex): Promise; }; + const policyMigration = createRequire(import.meta.url)( + "../migrations/20261007030000_oauth_policy_access.cjs", + ) as { up(db: Knex): Promise; down(db: Knex): Promise }; try { await f.db.transaction(async (trx) => { + await policyMigration.down(trx); await migration.down(trx); await migration.up(trx); + await policyMigration.up(trx); assert.equal( (await trx("public.asmblyr_oauth_apps").first()).id, f.application.id, diff --git a/apps/core/test/oauth-policies.integration.test.ts b/apps/core/test/oauth-policies.integration.test.ts new file mode 100644 index 0000000..02fc5f6 --- /dev/null +++ b/apps/core/test/oauth-policies.integration.test.ts @@ -0,0 +1,292 @@ +import "./support/require-test-database.js"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { createLocalJWKSet, jwtVerify } from "jose"; +import { oauthFixture } from "./support/oauth-provider.js"; + +const monitor = "lavinmq.tag:monitoring"; +const administrator = "lavinmq.tag:administrator"; +const profile = "openid profile email"; + +test("application policies enforce personal roles throughout OAuth authorization", async (t) => { + const f = await oauthFixture(); + const policyIds: string[] = []; + const input = { + ...f.input, + policyManaged: true, + scopes: [monitor, administrator], + scopeLabels: { [monitor]: "Monitoring", [administrator]: "Administration" }, + }; + async function policy(scopes: string[], userIds = [f.users[0]]) { + const result = await f.app.inject({ + method: "POST", + url: "/policies", + headers: f.auth, + payload: { + name: `OAuth policy ${randomUUID()}`, + permissions: [], + userIds, + applications: [{ appId: f.application.id, scopes }], + }, + }); + assert.equal(result.statusCode, 201, result.body); + const id = result.json().data.id as string; + policyIds.push(id); + return id; + } + async function change(id: string, scopes: string[], userIds = [f.users[0]]) { + return f.app.inject({ + method: "PATCH", + url: `/policies/${id}`, + headers: f.auth, + payload: { + name: `OAuth policy ${id}`, + permissions: [], + userIds, + applications: [{ appId: f.application.id, scopes }], + }, + }); + } + async function authorize(headers = f.auth) { + const flow = await f.flow(f.application.id, profile, headers); + const response = await flow.consent(); + assert.equal(response.statusCode, 303, response.body); + const code = new URL(String(response.headers.location)).searchParams.get( + "code", + ); + assert.ok(code, response.headers.location); + return { flow, code }; + } + async function roles(code: string, verifier: string) { + const token = await f.exchange(code, verifier); + assert.equal(token.statusCode, 200, token.body); + const jwks = (await f.app.inject({ url: "/oauth/jwks" })).json(); + const { payload } = await jwtVerify( + token.json().access_token, + createLocalJWKSet(jwks), + { issuer: f.config.issuer, audience: "lavinmq" }, + ); + assert.deepEqual( + String(payload.scope).split(" ").sort(), + profile.split(" ").sort(), + ); + assert.equal(payload.superuser, undefined); + return (payload.resource_access as Record) + .lavinmq.roles; + } + try { + assert.equal(f.application.policyManaged, false); + const saved = await f.app.inject({ + method: "PUT", + url: `/oauth-apps/${f.application.id}`, + headers: f.auth, + payload: input, + }); + assert.equal(saved.statusCode, 200, saved.body); + await t.test( + "catalog is restricted and a superuser without a policy cannot sign in", + async () => { + assert.equal( + (await f.app.inject({ url: "/policies/applications" })).statusCode, + 401, + ); + assert.equal( + ( + await f.app.inject({ + url: "/policies/applications", + headers: f.otherAuth, + }) + ).statusCode, + 403, + ); + const catalog = await f.app.inject({ + url: "/policies/applications", + headers: f.auth, + }); + assert.equal(catalog.json().data[0].scopeLabels[monitor], "Monitoring"); + const flow = await f.flow(f.application.id, profile); + assert.equal(flow.details.json().data.allowed, false); + assert.match( + String((await flow.consent()).headers.location), + /error=access_denied/, + ); + }, + ); + const monitorPolicy = await policy([monitor]); + const adminPolicy = await policy([administrator], [f.users[1]]); + await t.test( + "users receive only their own roles, independently of the common login request", + async () => { + const first = await authorize(); + assert.deepEqual(first.flow.details.json().data.servicePermissions, [ + { scope: monitor, name: "Monitoring" }, + ]); + assert.deepEqual(await roles(first.code, first.flow.verifier), [ + monitor, + ]); + const second = await authorize(f.otherAuth); + assert.deepEqual(await roles(second.code, second.flow.verifier), [ + administrator, + ]); + const forged = new URLSearchParams(first.flow.query); + forged.set("scope", `${profile} ${administrator}`); + const rejected = await f.app.inject({ url: `/oauth/auth?${forged}` }); + assert.match(String(rejected.headers.location), /error=invalid_scope/); + }, + ); + await t.test( + "policy editing rejects foreign scopes and non-administrators atomically", + async () => { + const denied = await f.app.inject({ + method: "PATCH", + url: `/policies/${monitorPolicy}`, + headers: f.otherAuth, + payload: { name: "Changed" }, + }); + assert.equal(denied.statusCode, 403); + assert.equal( + (await change(monitorPolicy, ["another-service.admin"])).statusCode, + 400, + ); + const current = await f.app.inject({ + url: `/policies/${monitorPolicy}`, + headers: f.auth, + }); + assert.deepEqual(current.json().data.applications, [ + { appId: f.application.id, scopes: [monitor] }, + ]); + }, + ); + await t.test( + "pending codes cannot acquire newly granted roles and new grants need consent", + async () => { + const pending = await authorize(); + const staleScreen = await f.flow(f.application.id, profile); + assert.equal( + (await change(monitorPolicy, [monitor, administrator])).statusCode, + 200, + ); + assert.equal((await staleScreen.decision()).statusCode, 409); + assert.deepEqual(await roles(pending.code, pending.flow.verifier), [ + monitor, + ]); + const fresh = await f.flow(f.application.id, profile); + assert.equal(fresh.details.json().data.canReuseConsent, false); + assert.equal( + (await fresh.decision(true, { reuse: true })).statusCode, + 409, + ); + const accepted = await fresh.consent(); + const code = new URL( + String(accepted.headers.location), + ).searchParams.get("code")!; + assert.deepEqual(await roles(code, fresh.verifier), [ + administrator, + monitor, + ]); + }, + ); + await t.test( + "removing permissions before code exchange reduces the resulting token", + async () => { + const pending = await authorize(); + assert.equal((await change(monitorPolicy, [monitor])).statusCode, 200); + assert.deepEqual(await roles(pending.code, pending.flow.verifier), [ + monitor, + ]); + }, + ); + await t.test( + "policies combine; removing the last assignment rejects a pending token", + async () => { + assert.equal( + (await change(adminPolicy, [administrator], f.users)).statusCode, + 200, + ); + const combined = await authorize(); + assert.deepEqual(await roles(combined.code, combined.flow.verifier), [ + administrator, + monitor, + ]); + const pending = await authorize(); + await change(monitorPolicy, [monitor], []); + await change(adminPolicy, [administrator], [f.users[1]]); + const rejected = await f.exchange(pending.code, pending.flow.verifier); + assert.notEqual(rejected.statusCode, 200); + }, + ); + await t.test( + "legacy edits preserve policy management and omitted application grants", + async () => { + const saved = await f.app.inject({ + method: "PUT", + url: `/oauth-apps/${f.application.id}`, + headers: f.auth, + payload: { ...f.input, scopes: input.scopes }, + }); + assert.equal(saved.json().data.application.policyManaged, true); + const edited = await f.app.inject({ + method: "PATCH", + url: `/policies/${adminPolicy}`, + headers: f.auth, + payload: { + name: "Legacy edit", + permissions: [], + userIds: [f.users[1]], + }, + }); + assert.equal(edited.statusCode, 200, edited.body); + const detail = ( + await f.app.inject({ + url: `/policies/${adminPolicy}`, + headers: f.auth, + }) + ).json().data; + assert.deepEqual(detail.applications, [ + { appId: f.application.id, scopes: [administrator] }, + ]); + }, + ); + await t.test( + "catalog removal removes grants permanently, and disabled mode hides the application", + async () => { + await f.app.inject({ + method: "PUT", + url: `/oauth-apps/${f.application.id}`, + headers: f.auth, + payload: { ...input, scopes: [monitor], scopeLabels: {} }, + }); + await f.app.inject({ + method: "PUT", + url: `/oauth-apps/${f.application.id}`, + headers: f.auth, + payload: input, + }); + const current = ( + await f.app.inject({ + url: `/policies/${adminPolicy}`, + headers: f.auth, + }) + ).json().data; + assert.deepEqual(current.applications[0].scopes, []); + await f.app.inject({ + method: "PUT", + url: `/oauth-apps/${f.application.id}`, + headers: f.auth, + payload: { ...input, policyManaged: false }, + }); + const catalog = ( + await f.app.inject({ url: "/policies/applications", headers: f.auth }) + ).json().data; + assert.equal( + catalog.some((app: { id: string }) => app.id === f.application.id), + false, + ); + }, + ); + } finally { + await f.db("public.asmblyr_policies").whereIn("id", policyIds).delete(); + await f.close(); + } +}); diff --git a/apps/core/test/oauth-policy-migration.integration.test.ts b/apps/core/test/oauth-policy-migration.integration.test.ts new file mode 100644 index 0000000..5ba6392 --- /dev/null +++ b/apps/core/test/oauth-policy-migration.integration.test.ts @@ -0,0 +1,49 @@ +import "./support/require-test-database.js"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import type { Knex } from "knex"; +import { oauthFixture } from "./support/oauth-provider.js"; + +const migration = createRequire(import.meta.url)( + "../migrations/20261007030000_oauth_policy_access.cjs", +) as { up(db: Knex): Promise; down(db: Knex): Promise }; + +test("application-policy migration preserves legacy access and refuses loss of configuration", async () => { + const f = await oauthFixture(); + try { + await f.db.transaction(async (trx) => { + await migration.down(trx); + await migration.up(trx); + const row = await trx("public.asmblyr_oauth_apps") + .where({ id: f.application.id }) + .first(); + assert.equal(row.policy_managed, false); + assert.equal(row.access_mode, "selected"); + assert.deepEqual(row.scopes, f.input.scopes); + assert.deepEqual(row.scope_labels, {}); + assert.deepEqual(await trx("public.asmblyr_policy_oauth_apps"), []); + }); + await f + .db("public.asmblyr_oauth_apps") + .where({ id: f.application.id }) + .update({ policy_managed: true }); + await assert.rejects( + f.db.transaction((trx) => migration.down(trx)), + /Cannot discard configured application policies/, + ); + await f + .db("public.asmblyr_oauth_apps") + .where({ id: f.application.id }) + .update({ + policy_managed: false, + scope_labels: JSON.stringify({ [f.input.scopes[0]]: "Monitoring" }), + }); + await assert.rejects( + f.db.transaction((trx) => migration.down(trx)), + /Cannot discard configured application policies/, + ); + } finally { + await f.close(); + } +}); diff --git a/apps/core/test/support/oauth-provider.ts b/apps/core/test/support/oauth-provider.ts index 9fd3731..88bf9d4 100644 --- a/apps/core/test/support/oauth-provider.ts +++ b/apps/core/test/support/oauth-provider.ts @@ -120,7 +120,16 @@ export async function oauthFixture( method: "POST", url: `/oauth-interactions/${uid}`, headers: { ...currentHeaders, cookie: cookies() }, - payload: { approve, userId: details.json().data.userId, ...overrides }, + payload: { + approve, + userId: details.json().data.userId, + serviceScopes: details + .json() + .data.servicePermissions?.map( + (permission: { scope: string }) => permission.scope, + ), + ...overrides, + }, }); } async function resume(redirectTo: string) { diff --git a/apps/core/test/user-relations.integration.test.ts b/apps/core/test/user-relations.integration.test.ts new file mode 100644 index 0000000..98f732c --- /dev/null +++ b/apps/core/test/user-relations.integration.test.ts @@ -0,0 +1,289 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; +import { systemCollectionFixture as fixture } from "./support/system-collections.js"; +import { listUserReferences } from "../src/auth/user-references.js"; + +test("user references create native FKs and validate record and draft writes", async (t) => { + const { db, call, member } = await fixture(t); + const name = `user_ref_${randomUUID().replaceAll("-", "").slice(0, 10)}`; + await call( + "POST", + "/collections", + { name, fields: [{ name: "name", type: "text" }] }, + 201, + ); + const target = await db("asmblyr_users") + .insert({ + email: `${randomUUID()}@example.test`, + display_name: "Department manager", + }) + .returning("id"); + const userId = target[0].id; + const relation = await call( + "POST", + `/collections/${name}/relations`, + { name: "manager_id", targetCollection: "@users", onDelete: "setNull" }, + 201, + ); + assert.deepEqual( + relation.fields.find((f: { name: string }) => f.name === "manager_id") + .relation, + { + kind: "m2o", + collection: "@users", + primaryKey: { name: "id", type: "uuid" }, + onDelete: "setNull", + }, + ); + assert.equal( + await db("asmblyr_collections").where({ name: "asmblyr_users" }).first(), + undefined, + ); + const [label] = await call("GET", `/users/references?ids=${userId}`); + assert.deepEqual(Object.keys(label).sort(), ["id", "label"]); + assert.ok(label.label.startsWith("Department manager · ")); + const item = await call( + "POST", + `/items/${name}`, + { name: "Engineering", manager_id: userId }, + 201, + ); + assert.equal(item.manager_id, userId); + const filter = encodeURIComponent( + JSON.stringify({ + logic: "and", + children: [{ field: "manager_id", op: "eq", value: userId }], + }), + ); + const matching = await call("GET", `/items/${name}?filter=${filter}`); + assert.deepEqual( + matching.map((row: { id: number }) => row.id), + [item.id], + ); + const nestedFilter = encodeURIComponent( + JSON.stringify({ + logic: "and", + children: [{ field: "manager_id.email", op: "contains", value: "@" }], + }), + ); + await call("GET", `/items/${name}?filter=${nestedFilter}`, undefined, 400); + await call( + "PATCH", + `/items/${name}/${item.id}`, + { manager_id: randomUUID() }, + 409, + ); + await call( + "PATCH", + `/items/${name}/${item.id}`, + { manager_id: "invalid" }, + 400, + ); + await call( + "POST", + `/items/${name}/commit`, + { values: { manager_id: userId } }, + 200, + ); + await call( + "POST", + `/items/${name}/commit`, + { + values: {}, + references: { + manager_id: { values: { email: "injection@example.test" } }, + }, + }, + 400, + ); + await call( + "PATCH", + `/collections/${name}/fields/manager_id`, + { defaultValue: userId }, + 400, + ); + await call("PUT", `/collections/${name}/fields/manager_id/presentation`, { + label: "Руководитель", + width: "half", + }); + await db("asmblyr_users") + .where({ id: userId }) + .update({ status: "disabled" }); + await call("POST", `/items/${name}`, { manager_id: userId }, 409); + assert.equal( + (await call("GET", `/users/references?ids=${userId}`))[0].id, + userId, + ); + assert.deepEqual( + await call("GET", "/users/references?q=Department%20manager"), + [], + ); + await call("PATCH", `/items/${name}/${item.id}`, { + name: "New name", + manager_id: userId, + }); + await db("asmblyr_users").where({ id: userId }).delete(); + assert.equal( + (await call("GET", `/items/${name}/${item.id}`)).manager_id, + null, + ); + await call( + "DELETE", + `/collections/${name}/fields/manager_id`, + undefined, + 204, + ); + assert.equal( + await db("asmblyr_relations").where({ source_collection: name }).first(), + undefined, + ); + await call( + "POST", + `/collections/${name}/relations`, + { name: "manager_id", targetCollection: "@users" }, + 201, + ); + const restricted = await call( + "POST", + `/items/${name}`, + { manager_id: member.id }, + 201, + ); + await assert.rejects( + db("asmblyr_users").where({ id: member.id }).delete(), + (error: { code?: string }) => error.code === "23503", + ); + await call("DELETE", `/items/${name}/${restricted.id}`, undefined, 204); + await call("DELETE", `/collections/${name}`, undefined, 204); +}); + +test("user reference permissions are enforced for selection, writes, service accounts and revocation", async (t) => { + const { db, call, member } = await fixture(t); + const name = `user_perm_${randomUUID().replaceAll("-", "").slice(0, 10)}`; + await call("POST", "/collections", { name, fields: [] }, 201); + await call( + "POST", + `/collections/${name}/relations`, + { name: "manager_id", targetCollection: "@users", onDelete: "setNull" }, + 201, + ); + const permissions = ["create", "read", "update"].map((action) => ({ + collection: name, + action, + fields: ["*"], + })); + const policy = await call( + "POST", + "/policies", + { name: randomUUID(), permissions, userIds: [member.id] }, + 201, + ); + await call("GET", "/users/references", undefined, 401, -1); + await call("GET", "/users/references", undefined, 403, 1); + await assert.rejects( + listUserReferences( + db, + { + kind: "service", + id: randomUUID(), + keyId: null, + federationId: null, + superuser: false, + }, + {}, + ), + { statusCode: 403 }, + ); + await call("POST", `/items/${name}`, { manager_id: member.id }, 403, 1); + const item = await call( + "POST", + `/items/${name}`, + { manager_id: member.id }, + 201, + ); + // Retaining an existing reference does not require directory access. + await call( + "PATCH", + `/items/${name}/${item.id}`, + { manager_id: member.id }, + 200, + 1, + ); + await call( + "PATCH", + `/items/${name}/${item.id}`, + { manager_id: null }, + 200, + 1, + ); + await call("PATCH", `/policies/${policy.id}`, { + name: policy.name, + permissions: [ + ...permissions, + { section: "users", action: "read", fields: ["*"] }, + ], + userIds: [member.id], + }); + await call("POST", `/items/${name}`, { manager_id: member.id }, 201, 1); + assert.ok( + (await call("GET", `/users/references?ids=${member.id}`, undefined, 200, 1)) + .length, + ); + await call("GET", "/users/references?limit=1000", undefined, 400); + await call("GET", "/users/references?ids=bad", undefined, 400); + await call("GET", "/users/references?fields=password_hash", undefined, 400); + await call("GET", "/items/asmblyr_users", undefined, 404); + await call("GET", "/items/%40users", undefined, 400); + await call("DELETE", `/policies/${policy.id}`, undefined, 204); + await call("GET", "/users/references", undefined, 403, 1); + await call("DELETE", `/collections/${name}`, undefined, 204); +}); + +test("system relation configuration and migration cannot silently discard references", async (t) => { + const { db, call } = await fixture(t); + const name = `user_ddl_${randomUUID().replaceAll("-", "").slice(0, 10)}`; + await call("POST", "/collections", { name, fields: [] }, 201); + for (const extra of [ + { kind: "o2m" }, + { kind: "m2m" }, + { reverseField: "departments" }, + { onDelete: "cascade" }, + { onDelete: "setDefault", defaultValue: randomUUID() }, + { targetCollection: "asmblyr_users" }, + { targetCollection: "@policies" }, + ]) { + await call( + "POST", + `/collections/${name}/relations`, + { name: "manager_id", targetCollection: "@users", ...extra }, + extra.targetCollection === "asmblyr_users" ? 403 : 400, + ); + } + assert.equal(await db.schema.hasColumn(name, "manager_id"), false); + const migration = createRequire(import.meta.url)( + "../migrations/20261007040000_system_user_relations.cjs", + ); + await db.transaction(async (trx) => { + await migration.down(trx); + await migration.up(trx); + }); + await call( + "POST", + `/collections/${name}/relations`, + { name: "manager_id", targetCollection: "@users" }, + 201, + ); + await assert.rejects( + db.transaction((trx) => migration.down(trx)), + /Remove system user relations/, + ); + assert.equal( + (await call("GET", "/collections")).find( + (c: { name: string }) => c.name === name, + ).fields[0].type, + "relation", + ); + await call("DELETE", `/collections/${name}`, undefined, 204); +}); diff --git a/apps/ui/src/components/access/policy-applications.tsx b/apps/ui/src/components/access/policy-applications.tsx new file mode 100644 index 0000000..e10f012 --- /dev/null +++ b/apps/ui/src/components/access/policy-applications.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Checkbox } from "@asmblyr-collaborative/kit/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; +import { useUiCopy } from "@/lib/ui-copy"; +import { apiRequest } from "@/lib/api-request"; + +interface ApplicationGrant { + appId: string; + scopes: string[]; +} +interface PolicyApplication { + id: string; + name: string; + enabled: boolean; + scopes: string[]; + scopeLabels: Record; +} + +export function usePolicyApplications(open: boolean, policyId?: string) { + const [catalog, setCatalog] = useState([]); + const [selected, setSelected] = useState([]); + const [initial, setInitial] = useState("[]"); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + if (!open) { + return; + } + let active = true; + Promise.all([ + apiRequest("/api/policies/applications"), + policyId + ? apiRequest<{ applications: ApplicationGrant[] }>( + `/api/policies/${policyId}`, + ) + : Promise.resolve({ applications: [] }), + ]) + .then(([apps, policy]) => { + if (!active) { + return; + } + setCatalog(apps); + setSelected(policy.applications); + setInitial(JSON.stringify(policy.applications)); + setLoading(false); + }) + .catch((cause: unknown) => { + if (!active) { + return; + } + setError( + cause instanceof Error + ? cause.message + : "Не удалось загрузить приложения", + ); + setLoading(false); + }); + return () => { + active = false; + }; + }, [open, policyId, attempt]); + return { + catalog, + selected, + setSelected, + loading, + error, + retry: () => { + setLoading(true); + setError(""); + setAttempt((value) => value + 1); + }, + dirty: JSON.stringify(selected) !== initial, + }; +} + +export function PolicyApplications({ + state, + disabled, +}: { + state: ReturnType; + disabled: boolean; +}) { + const copy = useUiCopy(); + if (state.loading) { + return ( +

+ {copy("Загружаем приложения…")} +

+ ); + } + if (state.error) { + return ( +

+ {copy(state.error)} +

+ ); + } + if (!state.catalog.length) { + return ( +

+ {copy( + "Включите управление через политики в настройках OAuth-приложения, чтобы назначить ему доступ здесь.", + )} +

+ ); + } + return ( +
+

+ {copy( + "Разрешения складываются с другими политиками пользователя. Изменения применяются при новом входе; выданный токен действует до 5 минут.", + )} +

+ {state.catalog.map((app) => { + const grant = state.selected.find((grant) => grant.appId === app.id); + function replace(next?: ApplicationGrant) { + state.setSelected((current) => [ + ...current.filter((grant) => grant.appId !== app.id), + ...(next ? [next] : []), + ]); + } + return ( +
+
+
+

{app.name}

+

+ {app.enabled + ? copy("Разрешить вход") + : copy("Приложение выключено · вход недоступен")} +

+
+ + replace(checked ? { appId: app.id, scopes: [] } : undefined) + } + /> +
+ {grant && ( +
+ {app.scopes.length === 0 && ( +

+ {copy( + "Только вход и передача профиля. Дополнительные разрешения не настроены.", + )} +

+ )} + {app.scopes.map((scope) => ( + + ))} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/ui/src/components/access/policy-editor-dialog.tsx b/apps/ui/src/components/access/policy-editor-dialog.tsx index 339b6a2..d4f6e51 100644 --- a/apps/ui/src/components/access/policy-editor-dialog.tsx +++ b/apps/ui/src/components/access/policy-editor-dialog.tsx @@ -21,6 +21,10 @@ import { PolicyPermissionsMatrix } from "./policy-permissions-matrix"; import { accessRequest } from "@/lib/access-request"; import { type AccessUser, type Permission, type Policy } from "./types"; import { useUiCopy } from "@/lib/ui-copy"; +import { + PolicyApplications, + usePolicyApplications, +} from "./policy-applications"; export function PolicyEditorDialog({ open, @@ -46,6 +50,7 @@ export function PolicyEditorDialog({ onChange: () => Promise; }) { const copy = useUiCopy(); + const applications = usePolicyApplications(open, policy?.id); const [name, setName] = useState(policy?.name ?? ""); const [grants, setGrants] = useState(() => @@ -67,7 +72,11 @@ export function PolicyEditorDialog({ async function save(event: FormEvent, close: () => void) { event.preventDefault(); - if (busy || readOnly) { + if ( + busy || + readOnly || + (!assignmentOnly && (applications.loading || applications.error)) + ) { return; } setBusy(true); @@ -86,6 +95,7 @@ export function PolicyEditorDialog({ name: name.trim(), permissions: [...grants, ...sections], userIds, + applications: applications.selected, }, ); await onChange(); @@ -173,7 +183,8 @@ export function PolicyEditorDialog({ } busy={busy} hasUnsavedChanges={ - JSON.stringify({ name, grants, sections, userIds }) !== initialDraft + JSON.stringify({ name, grants, sections, userIds }) !== initialDraft || + applications.dirty } onClose={onClose} > @@ -191,6 +202,22 @@ export function PolicyEditorDialog({ {copy(error)}

)} + {applications.error && ( +
+ {copy("Не удалось загрузить приложения")} + +
+ )} {assignmentOnly && !readOnly && (

{copy( @@ -240,13 +267,42 @@ export function PolicyEditorDialog({ className="space-y-6 data-[state=inactive]:hidden" > {permissionFields} + ) : ( - <> - {permissionFields} - {usersPicker} - + + + + {copy("Права политики")} + + + {copy("Приложения")} + + + {copy("Пользователи")} + + + + {permissionFields} + + + + + {usersPicker} + )}

{policy && !readOnly && !assignmentOnly ? ( @@ -274,7 +330,12 @@ export function PolicyEditorDialog({ {!readOnly && ( diff --git a/apps/ui/src/components/collections/field-rules-settings.tsx b/apps/ui/src/components/collections/field-rules-settings.tsx index 6204bac..19a8ebb 100644 --- a/apps/ui/src/components/collections/field-rules-settings.tsx +++ b/apps/ui/src/components/collections/field-rules-settings.tsx @@ -42,7 +42,9 @@ export function FieldRulesSettings({ ) ?? []; const relations = fields.filter( (f) => - f.relation?.kind === "m2o" && f.relation.collection !== collection?.name, + f.relation?.kind === "m2o" && + f.relation.collection !== "@users" && + f.relation.collection !== collection?.name, ); const selected = relations.find((f) => f.name === rules.computed?.relation); const target = catalog.find((c) => c.name === selected?.relation?.collection); @@ -90,78 +92,84 @@ export function FieldRulesSettings({ container={container ?? null} onChange={(requiredWhen) => set({ requiredWhen })} /> -
-

- {copy("Взять значение из связи")} -

- ({ - value: f.name, - label: f.presentation?.label || f.name, - })), - ]} - onChange={(relation) => { - const source = relations.find((f) => f.name === relation); - const target = catalog.find( - (c) => c.name === source?.relation?.collection, - ); - set({ - computed: - relation === "$none" - ? undefined - : { - relation, - field: target?.fields.find(compatible)?.name ?? "", - }, - }); - }} - /> - {rules.computed && ( + {current?.relation?.collection !== "@users" && ( +
+

+ {copy("Взять значение из связи")} +

({ + { value: "$none", label: copy("Не вычислять") }, + ...relations.map((f) => ({ value: f.name, label: f.presentation?.label || f.name, - })) ?? []), + })), ]} - onChange={(field) => + onChange={(relation) => { + const source = relations.find((f) => f.name === relation); + const target = catalog.find( + (c) => c.name === source?.relation?.collection, + ); set({ - computed: { - ...rules.computed!, - field: field === "$empty" ? "" : field, - }, - }) - } + computed: + relation === "$none" + ? undefined + : { + relation, + field: target?.fields.find(compatible)?.name ?? "", + }, + }); + }} /> - )} -

- {copy( - "Сервер копирует значение при сохранении. Пользователю нужны права на источник и изменяемое поле. ", + {rules.computed && ( + ({ + value: f.name, + label: f.presentation?.label || f.name, + })) ?? []), + ]} + onChange={(field) => + set({ + computed: { + ...rules.computed!, + field: field === "$empty" ? "" : field, + }, + }) + } + /> )} -

-
- {type === "relation" && current && ( - onChange({ ...value, relationFilter })} - /> +

+ {copy( + "Сервер копирует значение при сохранении. Пользователю нужны права на источник и изменяемое поле. ", + )} +

+
)} + {type === "relation" && + current && + current.relation?.collection !== "@users" && ( + + onChange({ ...value, relationFilter }) + } + /> + )} {["text", "email"].includes(type) && (
diff --git a/apps/ui/src/components/collections/relation-behavior-settings.tsx b/apps/ui/src/components/collections/relation-behavior-settings.tsx index 2555d16..d0eb403 100644 --- a/apps/ui/src/components/collections/relation-behavior-settings.tsx +++ b/apps/ui/src/components/collections/relation-behavior-settings.tsx @@ -173,12 +173,16 @@ export function RelationBehaviorSettings({ {copy("Очистить внешний ключ")} )} - - {copy("Установить значение по умолчанию ")} - - - {copy("Удалить связанные записи ")} - + {targetCollection !== "@users" && ( + + {copy("Установить значение по умолчанию ")} + + )} + {targetCollection !== "@users" && ( + + {copy("Удалить связанные записи ")} + + )}
diff --git a/apps/ui/src/components/collections/relation-create-form.tsx b/apps/ui/src/components/collections/relation-create-form.tsx index fc0a476..8a06593 100644 --- a/apps/ui/src/components/collections/relation-create-form.tsx +++ b/apps/ui/src/components/collections/relation-create-form.tsx @@ -172,6 +172,11 @@ export function RelationCreateForm({ value={targetCollection} onValueChange={(value) => { setTargetCollection(value); + if (value === "@users") { + setReverseField(""); + editor.setDefaultValue(""); + editor.setOnDelete(editor.nullable ? "setNull" : "restrict"); + } setForeignKey(""); setReuseExisting(false); setMessage(""); @@ -185,6 +190,11 @@ export function RelationCreateForm({ + {kind === "m2o" && ( + + {copy("Пользователи · системная")} + + )} {collections.map((entry) => (
- {kind !== "o2m" && ( + {kind !== "o2m" && targetCollection !== "@users" && (

{scope.id}

- {scope.collection} + {scope.collection === "@users" + ? copy("Пользователи") + : scope.collection}

diff --git a/apps/ui/src/components/items/item-filter-options.ts b/apps/ui/src/components/items/item-filter-options.ts index 75b3171..b823fb2 100644 --- a/apps/ui/src/components/items/item-filter-options.ts +++ b/apps/ui/src/components/items/item-filter-options.ts @@ -179,6 +179,19 @@ export function filterScopes( for (const relationField of collection.fields) { const relation = relationField.relation; if (!relation) continue; + if ( + relation.collection === "@users" && + visible(collection, relationField.name) + ) { + scopes.push({ + id: relationField.name, + collection: "@users", + kind: "m2o", + fields: [], + displayField: "label", + }); + continue; + } const related = fields.filter((field) => field.name.startsWith(`${relationField.name}.`), ); diff --git a/apps/ui/src/components/items/item-filter-relation-value.tsx b/apps/ui/src/components/items/item-filter-relation-value.tsx index 3a07023..58ce0c6 100644 --- a/apps/ui/src/components/items/item-filter-relation-value.tsx +++ b/apps/ui/src/components/items/item-filter-relation-value.tsx @@ -89,7 +89,11 @@ export function ItemFilterRelationValue({ className="w-[min(26rem,calc(100vw-2rem))] overflow-hidden p-0" >
-

{scope.collection}

+

+ {scope.collection === "@users" + ? copy("Пользователи") + : scope.collection} +

{multiple ? copy("Выбрано {{value0}} из 20", { value0: selected.length }) @@ -127,7 +131,12 @@ export function ItemFilterRelationValue({ query={items.query} onQueryChange={items.setQuery} maxLength={100} - placeholder={copy("Найти запись в ") + scope.collection} + placeholder={ + copy("Найти запись в ") + + (scope.collection === "@users" + ? copy("Пользователи") + : scope.collection) + } > {items.loading ? (

c.name === field.relation?.collection && c.access.read, - ) - : undefined; + field.relation?.collection === "@users" + ? userReferenceCollection(copy("Пользователи")) + : field.relation?.kind === "m2o" + ? catalog.find( + (c) => c.name === field.relation?.collection && c.access.read, + ) + : undefined; const id = item[field.name] == null ? "" : String(item[field.name]); return field.type === "file" || field.type === "files" ? ( entry.name === field.relation?.collection, - ); + const systemUser = field.relation?.collection === "@users"; + const target = systemUser + ? userReferenceCollection(copy("Пользователи")) + : catalog.find((entry) => entry.name === field.relation?.collection); if (!target?.access.read) return (

@@ -87,7 +89,7 @@ export function RelationPicker({ } />
- {value && onOpen && ( + {value && onOpen && !systemUser && (
- +
+
+ +

+ {copy( + "Приложение появится в политиках. Вход и разрешения назначаются пользователям через их политики.", + )} +

+
+ change("policyManaged", value)} + /> +
+ {draft.policyManaged ? ( +

+ {copy( + "После сохранения назначьте приложение хотя бы одной политике. Без разрешения в политике вход закрыт, включая суперпользователей.", + )} +

+ ) : ( + + )} -
- -