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..61ecd1d
--- /dev/null
+++ b/apps/core/migrations/20261007040000_system_user_relations.cjs
@@ -0,0 +1,22 @@
+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..e7b1772
--- /dev/null
+++ b/apps/core/src/auth/user-references.ts
@@ -0,0 +1,103 @@
+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..4227f85 100644
--- a/apps/core/src/collections/relation-search.ts
+++ b/apps/core/src/collections/relation-search.ts
@@ -36,6 +36,18 @@ 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) => (
+
+
+ replace({
+ appId: app.id,
+ scopes:
+ checked === true
+ ? [...new Set([...grant.scopes, scope])]
+ : grant.scopes.filter((value) => value !== scope),
+ })
+ }
+ />
+
+
+ {app.scopeLabels[scope] ?? scope}
+
+ {app.scopeLabels[scope] && (
+
+ {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("Не удалось загрузить приложения")}
+
+ {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 && (
{busy ? copy("Сохранение…") : copy("Сохранить")}
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 && (
—
;
+ if (collection.system) {
+ return
{items.labels.get(id) ?? id}
;
+ }
return (
();
for (const column of columns) {
if (column.relation?.kind !== "m2o") continue;
- const target = catalog.find(
- (entry) => entry.name === column.relation?.collection,
- );
+ const target =
+ column.relation.collection === "@users"
+ ? userReferenceCollection("users")
+ : catalog.find((entry) => entry.name === column.relation?.collection);
if (!target?.access.read) continue;
const request = requests.get(target.name) ?? {
collection: target.name,
diff --git a/apps/ui/src/components/items/use-filter-relation-items.ts b/apps/ui/src/components/items/use-filter-relation-items.ts
index 3428302..9948028 100644
--- a/apps/ui/src/components/items/use-filter-relation-items.ts
+++ b/apps/ui/src/components/items/use-filter-relation-items.ts
@@ -6,7 +6,8 @@ export function useFilterRelationItems(
selected: string[],
open: boolean,
) {
- const key = scope.presenceField!.split(".")[1];
+ const key =
+ scope.collection === "@users" ? "id" : scope.presenceField!.split(".")[1];
const label =
scope.displayField ??
scope.fields
diff --git a/apps/ui/src/components/items/use-relation-items.ts b/apps/ui/src/components/items/use-relation-items.ts
index cf27771..391865e 100644
--- a/apps/ui/src/components/items/use-relation-items.ts
+++ b/apps/ui/src/components/items/use-relation-items.ts
@@ -7,6 +7,7 @@ import { useDraftPreviews } from "./record-draft-context";
import { originalCopy, type UiCopy } from "@/lib/ui-copy-types";
import { useUiCopy } from "@/lib/ui-copy";
+import { userReferenceUrl } from "./user-reference";
interface Option {
id: string;
@@ -28,7 +29,9 @@ async function readItems(
copy: UiCopy = originalCopy,
): Promise {
const response = await fetch(
- `${endpoint ?? `/api/items/${encodeURIComponent(collection)}`}?${params}`,
+ collection === "@users"
+ ? userReferenceUrl(params)
+ : `${endpoint ?? `/api/items/${encodeURIComponent(collection)}`}?${params}`,
{ signal },
);
if (!response.ok)
diff --git a/apps/ui/src/components/items/use-table-relation-labels.ts b/apps/ui/src/components/items/use-table-relation-labels.ts
index 551a3e8..55c60a9 100644
--- a/apps/ui/src/components/items/use-table-relation-labels.ts
+++ b/apps/ui/src/components/items/use-table-relation-labels.ts
@@ -10,6 +10,8 @@ import {
import type { Collection, Item } from "./types";
import type { ItemColumn } from "./use-item-columns";
import { useDraftPreviews } from "./record-draft-context";
+import { userReferenceUrl } from "./user-reference";
+import type { ItemList } from "./types";
export function useTableRelationLabels(
catalog: Collection[],
@@ -43,6 +45,31 @@ export function useTableRelationLabels(
void Promise.all(
jobs.map(async (target) => {
try {
+ if (target.collection === "@users") {
+ const response = await fetch(
+ userReferenceUrl(
+ new URLSearchParams({
+ limit: "20",
+ filter: JSON.stringify({
+ logic: "and",
+ children: [{ field: "id", op: "in", value: target.ids }],
+ }),
+ }),
+ ),
+ { signal: controller.signal },
+ );
+ if (!response.ok) {
+ return [];
+ }
+ const data = (await response.json()) as ItemList;
+ return data.data.map(
+ (item) =>
+ [
+ JSON.stringify([target.collection, String(item.id)]),
+ String(item.label),
+ ] as const,
+ );
+ }
const data = await asmblyr.items.list(
target.collection,
{
diff --git a/apps/ui/src/components/items/user-reference.ts b/apps/ui/src/components/items/user-reference.ts
new file mode 100644
index 0000000..bc3e9ec
--- /dev/null
+++ b/apps/ui/src/components/items/user-reference.ts
@@ -0,0 +1,48 @@
+import type { Collection } from "./types";
+
+/** Picker descriptor only. This is never added to the ordinary collection catalog. */
+export function userReferenceCollection(label: string): Collection {
+ return {
+ name: "@users",
+ system: true,
+ displayName: label,
+ displayField: "label",
+ folderId: null,
+ mode: "multiple",
+ primaryKey: { name: "id", type: "uuid" },
+ timestamps: { createdAt: false, updatedAt: false },
+ fields: [{ name: "label", type: "text", required: false, nullable: true }],
+ access: {
+ create: null,
+ read: ["id", "label"],
+ update: null,
+ delete: false,
+ structure: false,
+ },
+ };
+}
+
+export function userReferenceUrl(params: URLSearchParams): string {
+ const query = new URLSearchParams();
+ for (const key of ["q", "page", "limit"]) {
+ if (params.has(key)) {
+ query.set(key, params.get(key)!);
+ }
+ }
+ const filter = params.get("filter");
+ if (filter) {
+ const group = JSON.parse(filter);
+ const condition = group?.children?.[0];
+ if (
+ group?.logic !== "and" ||
+ group.children.length !== 1 ||
+ condition?.field !== "id" ||
+ condition.op !== "in" ||
+ !Array.isArray(condition.value)
+ ) {
+ throw new Error("Unsupported user reference filter");
+ }
+ query.set("ids", condition.value.join(","));
+ }
+ return `/api/users/references?${query}`;
+}
diff --git a/apps/ui/src/components/oauth/access-summary.ts b/apps/ui/src/components/oauth/access-summary.ts
index 7658a11..7f79f13 100644
--- a/apps/ui/src/components/oauth/access-summary.ts
+++ b/apps/ui/src/components/oauth/access-summary.ts
@@ -5,6 +5,9 @@ export function applicationAccessSummary(
application: OAuthApplication,
copy: UiCopy = originalCopy,
): string {
+ if (application.policyManaged) {
+ return copy("Через политики");
+ }
if (application.accessMode === "all")
return copy("Все активные пользователи");
if (application.accessMode === "domains") {
diff --git a/apps/ui/src/components/oauth/application-editor.tsx b/apps/ui/src/components/oauth/application-editor.tsx
index 5c39fe6..f7e4c90 100644
--- a/apps/ui/src/components/oauth/application-editor.tsx
+++ b/apps/ui/src/components/oauth/application-editor.tsx
@@ -42,6 +42,8 @@ export function ApplicationEditor({
emailDomains: [],
audience: "",
scopes: [],
+ policyManaged: false,
+ scopeLabels: {},
},
);
const [id, setId] = useState(initial?.id);
@@ -68,11 +70,19 @@ export function ApplicationEditor({
const emailDomains = draft.emailDomains
.map((domain) => domain.trim())
.filter(Boolean);
- if (draft.accessMode === "domains" && !emailDomains.length) {
+ if (
+ !draft.policyManaged &&
+ draft.accessMode === "domains" &&
+ !emailDomains.length
+ ) {
throw new Error(
copy("Добавьте хотя бы один домен почты во вкладке «Доступ»"),
);
}
+ const scopes = draft.scopes.map((scope) => scope.trim()).filter(Boolean);
+ if (new Set(scopes).size !== scopes.length) {
+ throw new Error(copy("Значения разрешений не должны повторяться"));
+ }
const payload = {
name: draft.name,
description: draft.description,
@@ -82,10 +92,18 @@ export function ApplicationEditor({
accessMode: draft.accessMode,
emailDomains: draft.accessMode === "domains" ? emailDomains : [],
audience: draft.audience,
+ policyManaged: draft.policyManaged,
+ scopeLabels: Object.fromEntries(
+ Object.entries(draft.scopeLabels)
+ .filter(
+ ([scope, label]) => scopes.includes(scope.trim()) && label.trim(),
+ )
+ .map(([scope, label]) => [scope.trim(), label.trim()]),
+ ),
redirectUris: draft.redirectUris
.map((uri) => uri.trim())
.filter(Boolean),
- scopes: draft.scopes.map((scope) => scope.trim()).filter(Boolean),
+ scopes,
};
const result = await apiRequest<{
application: OAuthApplication;
diff --git a/apps/ui/src/components/oauth/application-fields.tsx b/apps/ui/src/components/oauth/application-fields.tsx
index b30488c..382286f 100644
--- a/apps/ui/src/components/oauth/application-fields.tsx
+++ b/apps/ui/src/components/oauth/application-fields.tsx
@@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@asmblyr-collaborative/kit/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { ApplicationAccess } from "./application-access";
+import { ApplicationPermissions } from "./application-permissions";
import {
Select,
SelectContent,
@@ -166,12 +167,37 @@ export function ApplicationFields({
)}
-
+
+
+
+ {copy("Управлять доступом через политики")}
+
+
+ {copy(
+ "Приложение появится в политиках. Вход и разрешения назначаются пользователям через их политики.",
+ )}
+
+
+
change("policyManaged", value)}
+ />
+
+ {draft.policyManaged ? (
+
+ {copy(
+ "После сохранения назначьте приложение хотя бы одной политике. Без разрешения в политике вход закрыт, включая суперпользователей.",
+ )}
+
+ ) : (
+
+ )}
-
-
- {copy("Разрешённые scopes сервиса")}
-
-
+ )}
{copy(
"Токены действуют 5 минут. Уже выданный JWT может приниматься сервисом до истечения этого срока. Для новых прав или отзыва доступа повторный вход обязателен. ",
diff --git a/apps/ui/src/components/oauth/application-permissions.tsx b/apps/ui/src/components/oauth/application-permissions.tsx
new file mode 100644
index 0000000..b86f1b7
--- /dev/null
+++ b/apps/ui/src/components/oauth/application-permissions.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { Plus, X } from "lucide-react";
+import { Button } from "@asmblyr-collaborative/kit/ui/button";
+import { Input } from "@asmblyr-collaborative/kit/ui/input";
+import { useUiCopy } from "@/lib/ui-copy";
+import type { ApplicationDraft } from "./types";
+
+export function ApplicationPermissions({
+ draft,
+ onChange,
+}: {
+ draft: ApplicationDraft;
+ onChange: (draft: ApplicationDraft) => void;
+}) {
+ const copy = useUiCopy();
+ function update(index: number, scope: string, name: string) {
+ const scopes = [...draft.scopes];
+ const scopeLabels = { ...draft.scopeLabels };
+ delete scopeLabels[scopes[index]];
+ scopes[index] = scope;
+ if (name) scopeLabels[scope] = name;
+ onChange({ ...draft, scopes, scopeLabels });
+ }
+ return (
+
+
+
{copy("Разрешения приложения")}
+
+ {draft.policyManaged
+ ? copy(
+ "Каталог доступных прав. Пользователь получает только то, что назначено ему в политиках.",
+ )
+ : copy(
+ "Сервис может запросить эти права для любого пользователя, которому разрешён вход.",
+ )}
+
+
+ {draft.scopes.length > 0 && (
+
+ {draft.scopes.map((scope, index) => (
+
+ update(index, scope, event.target.value)}
+ />
+
+ update(
+ index,
+ event.target.value,
+ draft.scopeLabels[scope] ?? "",
+ )
+ }
+ />
+ {
+ const scopeLabels = { ...draft.scopeLabels };
+ delete scopeLabels[scope];
+ onChange({
+ ...draft,
+ scopeLabels,
+ scopes: draft.scopes.filter(
+ (_, position) => position !== index,
+ ),
+ });
+ }}
+ >
+
+
+
+ ))}
+
+ )}
+ = 30 || draft.scopes.includes("")}
+ onClick={() => onChange({ ...draft, scopes: [...draft.scopes, ""] })}
+ >
+
+ {copy("Добавить разрешение")}
+
+
+ );
+}
diff --git a/apps/ui/src/components/oauth/consent.tsx b/apps/ui/src/components/oauth/consent.tsx
index c017cb4..68b5119 100644
--- a/apps/ui/src/components/oauth/consent.tsx
+++ b/apps/ui/src/components/oauth/consent.tsx
@@ -18,6 +18,7 @@ export interface ConsentDetails {
scopes: string;
allowed: boolean;
canReuseConsent: boolean;
+ servicePermissions?: { scope: string; name: string }[];
}
export function OAuthConsent({ details }: { details: ConsentDetails }) {
@@ -48,7 +49,13 @@ export function OAuthConsent({ details }: { details: ConsentDetails }) {
const result = await apiRequest<{ redirectTo: string }>(
`/oauth/complete/${details.uid}`,
"POST",
- { approve, userId: details.userId },
+ {
+ approve,
+ userId: details.userId,
+ serviceScopes:
+ details.servicePermissions?.map((permission) => permission.scope) ??
+ [],
+ },
);
window.location.assign(result.redirectTo);
} catch (error) {
@@ -58,9 +65,11 @@ export function OAuthConsent({ details }: { details: ConsentDetails }) {
setBusy(false);
}
}
- const serviceScopes = details.scopes
- .split(" ")
- .filter((scope) => !["openid", "profile", "email"].includes(scope));
+ const serviceScopes = details.servicePermissions?.length
+ ? details.servicePermissions.map((permission) => permission.name)
+ : details.scopes
+ .split(" ")
+ .filter((scope) => !["openid", "profile", "email"].includes(scope));
let approvalLabel = copy("Разрешить и войти");
if (waitingForApproval)
approvalLabel = copy("Разрешить ({{value0}})", {
diff --git a/apps/ui/src/components/oauth/types.ts b/apps/ui/src/components/oauth/types.ts
index 165b3f3..536b7c6 100644
--- a/apps/ui/src/components/oauth/types.ts
+++ b/apps/ui/src/components/oauth/types.ts
@@ -10,6 +10,8 @@ export interface OAuthApplication {
emailDomains: string[];
audience: string;
scopes: string[];
+ policyManaged: boolean;
+ scopeLabels: Record;
}
export interface OAuthUser {
diff --git a/apps/ui/src/components/settings/connected-apps-panel.tsx b/apps/ui/src/components/settings/connected-apps-panel.tsx
index 22c2087..97a2c98 100644
--- a/apps/ui/src/components/settings/connected-apps-panel.tsx
+++ b/apps/ui/src/components/settings/connected-apps-panel.tsx
@@ -14,6 +14,7 @@ interface ConnectedApplication {
enabled: boolean;
audience: string;
scopes: string[];
+ serviceScopes: string[];
approvedAt: string;
lastUsedAt: string | null;
}
@@ -159,15 +160,17 @@ export function ConnectedAppsPanel() {
className="flex flex-wrap gap-1.5"
aria-label={copy("Предоставленные разрешения")}
>
- {app.scopes.map((scope) => (
-
- {copy(scopeLabels[scope] ?? scope)}
-
- ))}
+ {[...new Set([...app.scopes, ...(app.serviceScopes ?? [])])].map(
+ (scope) => (
+
+ {copy(scopeLabels[scope] ?? scope)}
+
+ ),
+ )}
{app.audience && (
diff --git a/apps/ui/test/user-reference.test.ts b/apps/ui/test/user-reference.test.ts
new file mode 100644
index 0000000..6dfc11a
--- /dev/null
+++ b/apps/ui/test/user-reference.test.ts
@@ -0,0 +1,63 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ userReferenceUrl,
+ userReferenceCollection,
+} from "../src/components/items/user-reference";
+import { tableRelationRequests } from "../src/components/items/table-relation-requests";
+import { filterScopes } from "../src/components/items/item-filter-options";
+import { relationSelectionCondition } from "../src/components/items/item-filter-relation";
+
+test("system user selection routes IDs to the bounded directory, never ordinary items", () => {
+ const params = new URLSearchParams({
+ limit: "20",
+ filter: JSON.stringify({
+ logic: "and",
+ children: [{ field: "id", op: "in", value: ["a", "b"] }],
+ }),
+ });
+ assert.equal(
+ userReferenceUrl(params),
+ "/api/users/references?limit=20&ids=a%2Cb",
+ );
+ assert.throws(() =>
+ userReferenceUrl(
+ new URLSearchParams({
+ filter: JSON.stringify({
+ logic: "or",
+ children: [{ field: "email", op: "contains", value: "@" }],
+ }),
+ }),
+ ),
+ );
+ const column = {
+ name: "manager",
+ label: "Manager",
+ type: "relation",
+ relation: {
+ kind: "m2o" as const,
+ collection: "@users",
+ primaryKey: { name: "id", type: "uuid" as const },
+ onDelete: "setNull" as const,
+ },
+ };
+ assert.deepEqual(
+ tableRelationRequests([], [{ manager: "a" }, { manager: "a" }], [column]),
+ [{ collection: "@users", key: "id", label: "label", ids: ["a"] }],
+ );
+ const source = {
+ ...userReferenceCollection("Departments"),
+ name: "departments",
+ fields: [{ ...column, required: false, nullable: true }],
+ access: { ...userReferenceCollection("").access, read: ["*"] },
+ };
+ const scope = filterScopes(source, []).find((s) => s.id === "manager");
+ assert.equal(scope?.collection, "@users");
+ assert.equal(scope?.presenceField, undefined);
+ assert.deepEqual(scope?.fields, []);
+ assert.deepEqual(relationSelectionCondition(scope!), {
+ field: "manager",
+ op: "eq",
+ value: "",
+ });
+});
diff --git a/docs/features/access.md b/docs/features/access.md
index 041f354..0ff5eb5 100644
--- a/docs/features/access.md
+++ b/docs/features/access.md
@@ -14,6 +14,28 @@ Core загружает активного principal и актуальные gra
Первичный ключ остаётся идентификатором читаемой записи. Запись без read может успешно вернуть `data: null`. System routes не являются обычными items: для них собственные проверки допуска.
+## Приложения в политиках
+
+Приложение с включённым управлением через политики появляется во вкладке
+«Приложения» редактора политики. «Разрешить вход» допускает пользователя к SSO;
+галочки ниже назначают права внешнего сервиса из его настроенного каталога.
+Несколько политик объединяются, deny-правил нет. Отсутствие назначения запрещает
+вход даже superuser; superuser Collaborative не означает администратора внешнего сервиса.
+
+`GET /policies/applications` возвращает каталог для редактора и требует человеческого
+доступа `policies/read` или `update`. Полная конфигурация `POST /policies` и
+`PATCH /policies/:id` принимает необязательное поле
+`applications: [{ appId, scopes }]`. Пустой `scopes` разрешает только вход,
+пустой `applications` снимает все назначения приложений этой политики.
+Пропуск `applications` при обновлении сохраняет назначения. Права приложения,
+права данных и пользователи сохраняются одной транзакцией. `GET /policies/:id`
+возвращает этот же список. Неизвестные приложения и разрешения отклоняются.
+
+Состав меняет только человек-superuser. Ограниченное назначение готовых политик
+действует и для их прав на приложения: менеджеру нужен явный разрешённый набор,
+он не меняет себя или superuser. OAuth-права не дают доступ к Core API.
+Подробности токенов и сроков отзыва — в [интеграциях](./integrations.md#доступ-через-политики).
+
## Условия записей
В редакторе политики нажмите действие коллекции: «Нет доступа», «Все записи» или «По условию». Условие состоит из поля, оператора и значения. Поиск поля учитывает его название и техническое имя; оператор зависит от типа. Значением может быть литерал или совместимый параметр из поиска. Параметры и их описание раскрываются по мере необходимости. Группы поддерживают И/ИЛИ; выбор полей свёрнут отдельно. «Применить» возвращает правило в черновик; доступ изменяется после общего сохранения политики.
diff --git a/docs/features/data.md b/docs/features/data.md
index f7e3818..e3e61ec 100644
--- a/docs/features/data.md
+++ b/docs/features/data.md
@@ -84,6 +84,47 @@ Core API: `GET /system-collections`, `POST|PUT
Изображения и галереи используют обычную файловую библиотеку; привязанные файлы
защищены от удаления. Интеграционные проверки: `node scripts/test.mjs core-system-collections`.
+### Связь обычной коллекции с пользователем
+
+В редакторе обычной коллекции выберите «Многие к одному», затем
+«Пользователи · системная». Например, поле `manager_id` связывает департамент
+с руководителем. Форма, фильтр и ячейки таблицы используют имя и почту пользователя;
+само значение в записи и SDK — UUID. Системные таблицы не появляются в обычном каталоге.
+
+Создание через `POST /collections/:name/relations` доступно суперпользователю:
+
+```json
+{
+ "kind": "m2o",
+ "name": "manager_id",
+ "targetCollection": "@users",
+ "nullable": true,
+ "onDelete": "setNull"
+}
+```
+
+`@users` — специальная цель связи, отличная от пользовательской коллекции `users`.
+Core создаёт UUID-столбец, индекс и внешний ключ на системного пользователя.
+Поддерживаются `restrict` и `setNull`; каскадное удаление записей, обратные поля,
+значения по умолчанию, вычисления и зависимые фильтры этой связи не поддерживаются.
+Фильтрация записей по UUID руководителя поддерживается; поиск внутри связанного
+профиля и переходы вроде `manager_id.email` не предоставляются.
+
+Для поиска пользователей, получения подписей и назначения пользователя требуется
+человеческая сессия с чтением раздела «Пользователи» (или суперпользователь).
+Права записи исходной коллекции также обязательны. Сервисные ключи не получают
+доступ к каталогу пользователей. Очистить необязательную связь можно с правом
+изменения самого поля. Назначить можно только активного пользователя.
+Сохранённую связь можно оставить без изменения, даже если пользователь отключён
+или у редактора больше нет доступа к каталогу пользователей.
+
+`GET /users/references` возвращает только `id` и `label`, принимает `q`, `page`,
+`limit` (1–100) и `ids` (до 100 UUID через запятую). Поиск показывает активных;
+запрос известных `ids` также позволяет подписать уже сохранённые ссылки на
+отключённых пользователей. Без права чтения подпись недоступна, UI показывает UUID.
+`/items/asmblyr_users` остаётся закрытым. Миграция не добавляет пользовательские
+поля автоматически; откат блокируется, пока существуют связи с пользователями.
+
### Materialized views
На `/admin/collections` суперпользователь может подключить готовое PostgreSQL
diff --git a/docs/features/integrations.md b/docs/features/integrations.md
index a1840a3..bda9807 100644
--- a/docs/features/integrations.md
+++ b/docs/features/integrations.md
@@ -37,6 +37,54 @@
Протокол обслуживает `oidc-provider` через `/oauth/*`. Полные protocol endpoints берутся из его discovery, а не из статической REST-матрицы. Административные `/oauth-apps` и `/oauth-interactions` описаны отдельно.
+### Доступ через политики
+
+В приложении на вкладке «Доступ» включите **«Управлять доступом через политики»**.
+На вкладке «Интеграция» задайте Audience и каталог разрешений: техническое значение
+для сервиса и понятное название для редактора. Сам каталог не выдаёт права.
+Затем в редакторе политики → «Приложения» разрешите вход, отметьте разрешения
+и назначьте политику пользователям. Разрешения всех политик пользователя объединяются.
+Без подходящей политики вход запрещён, в том числе superuser Collaborative.
+
+При включённом режиме прежние правила по пользователям и доменам не участвуют
+в проверке. При выключении снова действуют эти правила; назначенные права политик
+сохраняются, но не используются. Перед выключением проверьте прежний режим доступа.
+Существующие приложения сохраняют прежнее поведение: миграция не включает новый режим.
+
+В режиме политик приложение запрашивает только `openid profile email`.
+Core добавляет персональные разрешения в подписанный access token:
+
+```json
+{
+ "aud": "lavinmq",
+ "scope": "openid profile email",
+ "resource_access": {
+ "lavinmq": { "roles": ["lavinmq.tag:monitoring"] }
+ }
+}
+```
+
+Сервис должен поддерживать чтение этого claim; универсального преобразования
+произвольных политик в формат каждого сервиса нет. Для LavinMQ установите
+`resource_server_id = lavinmq`, `audience = lavinmq`, `verify_aud = true` и
+`mgmt_scopes = openid profile email`. Удалите общие сервисные scopes из запроса входа.
+Теги управления и разрешения на vhost/ресурсы задаются отдельно по правилам LavinMQ.
+
+На согласии видны названия персональных разрешений. Расширение прав требует нового
+согласия. Если набор изменился, пока открыто окно подтверждения, Core возвращает
+409: нужно начать вход заново и увидеть актуальные права. При обмене кода Core повторно проверяет доступ и пересекает текущие права
+с подтверждёнными: начатый вход не может получить позднее добавленное право.
+Изменения учитываются при новой выдаче токена. Уже выданный JWT внешний сервис может
+принимать до истечения его срока (5 минут); активное соединение сервис может
+закрывать по собственным правилам. Пользователи LavinMQ остаются OAuth-идентичностями,
+локальные учётные записи в брокере не создаются.
+
+REST-поля приложения: `policyManaged` (по умолчанию `false`), `scopes` (каталог),
+`scopeLabels` (словарь значение → название). Пропущенные `policyManaged` и
+`scopeLabels` при обновлении сохраняют текущую настройку. Удаление значения из
+каталога удаляет его из назначений политик; добавление обратно не восстанавливает
+старые права. Смена Audience или режима доступа сбрасывает согласия.
+
Локальный пример LavinMQ существует; его live-проверка зависит от поднятого контейнера и не выполняется обычным полным набором тестов. Не переносите разрешение HTTP для localhost в production: внешнему issuer нужны HTTPS, постоянные ключи подписи и корректные proxy/origin настройки.
## Личные подключения Google
diff --git a/docs/public/openapi.json b/docs/public/openapi.json
index 2c916b3..d5f799b 100644
--- a/docs/public/openapi.json
+++ b/docs/public/openapi.json
@@ -7023,7 +7023,7 @@
],
"operationId": "get__oauth_apps",
"summary": "GET /oauth-apps",
- "description": "Человек: oauth/read ИЛИ update; superuser bypass. Схемы тела и ответа пока не детализированы; см. реализацию обработчика. Источник: apps/core/src/oauth/routes.ts",
+ "description": "OAuth application configuration. policyManaged defaults to false; omission on update preserves policyManaged and scopeLabels. Catalog scopes require an audience, label keys must belong to scopes. In policy mode authorization requests use openid/profile/email only; personal permissions are emitted as resource_access[audience].roles. Changing audience or policy-management mode resets consent. Existing client type cannot change.",
"security": [
{
"bearerAuth": []
@@ -7034,11 +7034,130 @@
],
"parameters": [],
"responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": true,
+ "required": [
+ "name",
+ "description",
+ "enabled",
+ "clientType",
+ "redirectUris",
+ "userIds",
+ "audience",
+ "scopes"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 120
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "clientType": {
+ "enum": [
+ "public",
+ "confidential"
+ ]
+ },
+ "redirectUris": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "maxItems": 10
+ },
+ "userIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 1000
+ },
+ "accessMode": {
+ "enum": [
+ "all",
+ "selected",
+ "domains"
+ ]
+ },
+ "emailDomains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "audience": {
+ "type": "string",
+ "maxLength": 200
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "maxLength": 160
+ },
+ "maxItems": 30
+ },
+ "scopeLabels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string",
+ "maxLength": 120
+ }
+ },
+ "policyManaged": {
+ "type": "boolean",
+ "description": "Use assigned policies instead of user/domain rules. Omission preserves the existing value; defaults to false on creation."
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid application or permission catalog"
+ },
+ "401": {
+ "description": "Authentication required"
+ },
+ "403": {
+ "description": "Human identity and settings access required"
+ },
"default": {
- "description": "Контракт ответа смотрите в обработчике; эта операция описана на уровне маршрута и доступа."
+ "description": "Request or server error"
}
},
- "x-contract-level": "route-only",
+ "x-contract-level": "documented",
"x-source": "apps/core/src/oauth/routes.ts"
},
"post": {
@@ -7047,7 +7166,7 @@
],
"operationId": "post__oauth_apps",
"summary": "POST /oauth-apps",
- "description": "Человек: oauth/update; superuser bypass. Схемы тела и ответа пока не детализированы; см. реализацию обработчика. Источник: apps/core/src/oauth/routes.ts",
+ "description": "OAuth application configuration. policyManaged defaults to false; omission on update preserves policyManaged and scopeLabels. Catalog scopes require an audience, label keys must belong to scopes. In policy mode authorization requests use openid/profile/email only; personal permissions are emitted as resource_access[audience].roles. Changing audience or policy-management mode resets consent. Existing client type cannot change.",
"security": [
{
"bearerAuth": []
@@ -7058,12 +7177,231 @@
],
"parameters": [],
"responses": {
+ "201": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": [
+ "application"
+ ],
+ "properties": {
+ "application": {
+ "type": "object",
+ "additionalProperties": true,
+ "required": [
+ "name",
+ "description",
+ "enabled",
+ "clientType",
+ "redirectUris",
+ "userIds",
+ "audience",
+ "scopes"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 120
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "clientType": {
+ "enum": [
+ "public",
+ "confidential"
+ ]
+ },
+ "redirectUris": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "maxItems": 10
+ },
+ "userIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 1000
+ },
+ "accessMode": {
+ "enum": [
+ "all",
+ "selected",
+ "domains"
+ ]
+ },
+ "emailDomains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "audience": {
+ "type": "string",
+ "maxLength": 200
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "maxLength": 160
+ },
+ "maxItems": 30
+ },
+ "scopeLabels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string",
+ "maxLength": 120
+ }
+ },
+ "policyManaged": {
+ "type": "boolean",
+ "description": "Use assigned policies instead of user/domain rules. Omission preserves the existing value; defaults to false on creation."
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "clientSecret": {
+ "type": "string",
+ "description": "Returned once for a new confidential application"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid application or permission catalog"
+ },
+ "401": {
+ "description": "Authentication required"
+ },
+ "403": {
+ "description": "Human identity and settings access required"
+ },
"default": {
- "description": "Контракт ответа смотрите в обработчике; эта операция описана на уровне маршрута и доступа."
+ "description": "Request or server error"
}
},
- "x-contract-level": "route-only",
- "x-source": "apps/core/src/oauth/routes.ts"
+ "x-contract-level": "documented",
+ "x-source": "apps/core/src/oauth/routes.ts",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "name",
+ "description",
+ "enabled",
+ "clientType",
+ "redirectUris",
+ "userIds",
+ "audience",
+ "scopes"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 120
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "clientType": {
+ "enum": [
+ "public",
+ "confidential"
+ ]
+ },
+ "redirectUris": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "maxItems": 10
+ },
+ "userIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 1000
+ },
+ "accessMode": {
+ "enum": [
+ "all",
+ "selected",
+ "domains"
+ ]
+ },
+ "emailDomains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "audience": {
+ "type": "string",
+ "maxLength": 200
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "maxLength": 160
+ },
+ "maxItems": 30
+ },
+ "scopeLabels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string",
+ "maxLength": 120
+ }
+ },
+ "policyManaged": {
+ "type": "boolean",
+ "description": "Use assigned policies instead of user/domain rules. Omission preserves the existing value; defaults to false on creation."
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"/oauth-apps/{id}": {
@@ -7073,7 +7411,7 @@
],
"operationId": "put__oauth_apps_id",
"summary": "PUT /oauth-apps/:id",
- "description": "Человек: oauth/update; superuser bypass. Схемы тела и ответа пока не детализированы; см. реализацию обработчика. Источник: apps/core/src/oauth/routes.ts",
+ "description": "OAuth application configuration. policyManaged defaults to false; omission on update preserves policyManaged and scopeLabels. Catalog scopes require an audience, label keys must belong to scopes. In policy mode authorization requests use openid/profile/email only; personal permissions are emitted as resource_access[audience].roles. Changing audience or policy-management mode resets consent. Existing client type cannot change.",
"security": [
{
"bearerAuth": []
@@ -7091,14 +7429,233 @@
"type": "string"
}
}
- ],
- "responses": {
- "default": {
- "description": "Контракт ответа смотрите в обработчике; эта операция описана на уровне маршрута и доступа."
- }
- },
- "x-contract-level": "route-only",
- "x-source": "apps/core/src/oauth/routes.ts"
+ ],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "properties": {
+ "data": {
+ "type": "object",
+ "required": [
+ "application"
+ ],
+ "properties": {
+ "application": {
+ "type": "object",
+ "additionalProperties": true,
+ "required": [
+ "name",
+ "description",
+ "enabled",
+ "clientType",
+ "redirectUris",
+ "userIds",
+ "audience",
+ "scopes"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 120
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "clientType": {
+ "enum": [
+ "public",
+ "confidential"
+ ]
+ },
+ "redirectUris": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "maxItems": 10
+ },
+ "userIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 1000
+ },
+ "accessMode": {
+ "enum": [
+ "all",
+ "selected",
+ "domains"
+ ]
+ },
+ "emailDomains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "audience": {
+ "type": "string",
+ "maxLength": 200
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "maxLength": 160
+ },
+ "maxItems": 30
+ },
+ "scopeLabels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string",
+ "maxLength": 120
+ }
+ },
+ "policyManaged": {
+ "type": "boolean",
+ "description": "Use assigned policies instead of user/domain rules. Omission preserves the existing value; defaults to false on creation."
+ },
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "clientSecret": {
+ "type": "string",
+ "description": "Returned once for a new confidential application"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid application or permission catalog"
+ },
+ "401": {
+ "description": "Authentication required"
+ },
+ "403": {
+ "description": "Human identity and settings access required"
+ },
+ "default": {
+ "description": "Request or server error"
+ }
+ },
+ "x-contract-level": "documented",
+ "x-source": "apps/core/src/oauth/routes.ts",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "name",
+ "description",
+ "enabled",
+ "clientType",
+ "redirectUris",
+ "userIds",
+ "audience",
+ "scopes"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 120
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 1000
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "clientType": {
+ "enum": [
+ "public",
+ "confidential"
+ ]
+ },
+ "redirectUris": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "maxItems": 10
+ },
+ "userIds": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 1000
+ },
+ "accessMode": {
+ "enum": [
+ "all",
+ "selected",
+ "domains"
+ ]
+ },
+ "emailDomains": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "audience": {
+ "type": "string",
+ "maxLength": 200
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "maxLength": 160
+ },
+ "maxItems": 30
+ },
+ "scopeLabels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string",
+ "maxLength": 120
+ }
+ },
+ "policyManaged": {
+ "type": "boolean",
+ "description": "Use assigned policies instead of user/domain rules. Omission preserves the existing value; defaults to false on creation."
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"/oauth-apps/{id}/secret": {
@@ -7321,6 +7878,15 @@
},
"reuse": {
"type": "boolean"
+ },
+ "serviceScopes": {
+ "type": "array",
+ "maxItems": 30,
+ "items": {
+ "type": "string",
+ "maxLength": 160
+ },
+ "description": "Displayed servicePermissions.scope values. Required for explicit approval of a policy-managed application. A changed permission set returns 409 and requires a new sign-in."
}
},
"required": [
@@ -7892,6 +8458,100 @@
"x-source": "apps/core/src/policies/routes.ts"
}
},
+ "/policies/applications": {
+ "get": {
+ "tags": [
+ "policies"
+ ],
+ "operationId": "get__policies_applications",
+ "summary": "GET /policies/applications",
+ "description": "Catalog of policy-managed OAuth applications, including disabled applications. Requires policies/read or policies/update. Contains no client secrets. Catalog permissions do not grant access by themselves.",
+ "security": [
+ {
+ "bearerAuth": []
+ },
+ {
+ "browserSession": []
+ }
+ ],
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "Success",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "data"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "enabled",
+ "audience",
+ "scopes",
+ "scopeLabels"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "name": {
+ "type": "string"
+ },
+ "enabled": {
+ "type": "boolean"
+ },
+ "audience": {
+ "type": "string"
+ },
+ "scopes": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "maxLength": 160
+ },
+ "maxItems": 30
+ },
+ "scopeLabels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string",
+ "maxLength": 120
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid application or permission catalog"
+ },
+ "401": {
+ "description": "Authentication required"
+ },
+ "403": {
+ "description": "Human identity and settings access required"
+ },
+ "default": {
+ "description": "Request or server error"
+ }
+ },
+ "x-contract-level": "documented",
+ "x-source": "apps/core/src/policies/routes.ts"
+ }
+ },
"/presence": {
"post": {
"tags": [
@@ -16050,6 +16710,146 @@
}
}
},
+ "/users/references": {
+ "get": {
+ "tags": [
+ "users"
+ ],
+ "operationId": "get__users_references",
+ "summary": "GET /users/references",
+ "description": "Bounded system-user picker. Requires an authenticated human superuser or users section read/update permission. Returns only id and a display label (name/email); service accounts are denied. Search lists active users; explicit ids may resolve disabled users already referenced. This does not expose system tables through /items.",
+ "security": [
+ {
+ "bearerAuth": []
+ },
+ {
+ "browserSession": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "q",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "maxLength": 200
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 10000,
+ "default": 1
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100,
+ "default": 25
+ }
+ },
+ {
+ "name": "ids",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Up to 100 comma-separated UUIDs; invalid IDs and unknown query parameters are rejected."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "User reference page",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "data",
+ "labels",
+ "page"
+ ],
+ "properties": {
+ "data": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "id",
+ "label"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "label": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "labels": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "page": {
+ "type": "object",
+ "required": [
+ "number",
+ "size",
+ "total",
+ "sort",
+ "direction"
+ ],
+ "properties": {
+ "number": {
+ "type": "integer"
+ },
+ "size": {
+ "type": "integer"
+ },
+ "total": {
+ "type": "string"
+ },
+ "sort": {
+ "const": "label"
+ },
+ "direction": {
+ "const": "asc"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid query"
+ },
+ "401": {
+ "description": "Authentication required"
+ },
+ "403": {
+ "description": "User directory access denied"
+ }
+ },
+ "x-contract-level": "documented",
+ "x-source": "apps/core/src/auth/user-routes.ts"
+ }
+ },
"/workspaces": {
"get": {
"tags": [
diff --git a/docs/reference/endpoints.json b/docs/reference/endpoints.json
index 0cbf5a5..396d6b8 100644
--- a/docs/reference/endpoints.json
+++ b/docs/reference/endpoints.json
@@ -443,6 +443,10 @@
"access": "Человек: policies/read ИЛИ update; superuser bypass",
"public": false
},
+ "GET /policies/applications": {
+ "access": "Человек: policies/read ИЛИ update; каталог приложений с управлением через политики, без секретов",
+ "public": false
+ },
"POST /policies": {
"access": "Только человек-superuser: изменение состава политик и permissions",
"public": false
@@ -862,5 +866,9 @@
"POST /oauth/complete/:uid": {
"public": false,
"access": "Текущий пользователь, подписанные cookies взаимодействия OAuth и совпадающий Origin; идентификатор пользователя должен совпадать с сессией"
+ },
+ "GET /users/references": {
+ "access": "Человеческая сессия: superuser или чтение раздела users; только ID и подпись пользователя, ограниченные поиск и пагинация; сервисные ключи запрещены",
+ "public": false
}
}
diff --git a/docs/reference/routes.md b/docs/reference/routes.md
index 2120b49..4245b08 100644
--- a/docs/reference/routes.md
+++ b/docs/reference/routes.md
@@ -1,6 +1,6 @@
# Матрица HTTP-маршрутов
-Сгенерировано из Core и проверенного каталога доступа. 216 деклараций.
+Сгенерировано из Core и проверенного каталога доступа. 218 деклараций.
Это описание границ; их исполнение проверяют интеграционные тесты. Динамические маршруты плагинов и внутренние endpoints oidc-provider не перечисляются отдельно.
@@ -138,6 +138,7 @@
| `PUT /policies/:id/users` | Человек: superuser ИЛИ policies/update + политика в разрешённом наборе; атомарная замена назначений без изменения себя и superuser | `apps/core/src/policies/routes.ts` |
| `DELETE /policies/:id/users/:userId` | Человек: superuser ИЛИ policies/update + политика в разрешённом наборе; менеджер не меняет себя и superuser | `apps/core/src/policies/routes.ts` |
| `PUT /policies/:id/users/:userId` | Человек: superuser ИЛИ policies/update + политика в разрешённом наборе; менеджер не меняет себя и superuser | `apps/core/src/policies/routes.ts` |
+| `GET /policies/applications` | Человек: policies/read ИЛИ update; каталог приложений с управлением через политики, без секретов | `apps/core/src/policies/routes.ts` |
| `POST /presence` | Активная человеческая сессия; каждый heartbeat проверяет read коллекции/строки или доступ раздела. /files требует files/read либо update у человека, или superuser. Возвращает только ID, имя, аватар и число окон; без email/сессий/значений записи | `apps/core/src/presence/routes.ts` |
| `DELETE /presence/:clientId` | Активная человеческая сессия; удаляет только собственное окно этой сессии, без требования сохраняющегося read | `apps/core/src/presence/routes.ts` |
| `GET /public/files/:id/content` | Без авторизации: только явно опубликованный ready-файл (visibility=public). Приватный/удалённый файл — 404; no-store/CSP/nosniff. Без списка и метаданных. | `apps/core/src/files/public-routes.ts` |
@@ -218,6 +219,7 @@
| `PUT /users/me/workspace` | Человек, только собственный профиль/ресурс; workspace дополнительно проверяет видимость | `apps/core/src/workspaces/routes.ts` |
| `GET /users/profile-extension` | Активный пользователь с users/read; имя выбранной коллекции и ключа | `apps/core/src/auth/profile-extension-routes.ts` |
| `PUT /users/profile-extension` | Только human superuser; связывает обычную UUID-коллекцию, не выдаёт права и не удаляет данные | `apps/core/src/auth/profile-extension-routes.ts` |
+| `GET /users/references` | Человеческая сессия: superuser или чтение раздела users; только ID и подпись пользователя, ограниченные поиск и пагинация; сервисные ключи запрещены | `apps/core/src/auth/user-routes.ts` |
| `GET /workspaces` | Человек; только пространства с доступными коллекциями (superuser видит все) | `apps/core/src/workspaces/routes.ts` |
| `POST /workspaces` | Человек: superuser | `apps/core/src/workspaces/routes.ts` |
| `DELETE /workspaces/:id` | Человек: superuser | `apps/core/src/workspaces/routes.ts` |
diff --git a/examples/oauth-lavinmq/README.md b/examples/oauth-lavinmq/README.md
index ba113b2..6834d3f 100644
--- a/examples/oauth-lavinmq/README.md
+++ b/examples/oauth-lavinmq/README.md
@@ -21,6 +21,15 @@ docker compose -f examples/oauth-lavinmq/compose.yaml up -d
```
Gateway доступен на `http://localhost:15673`; настройки и данные брокера приватны.
+
+Для персональных прав включите в OAuth-приложении управление через политики.
+Добавьте разрешения `lavinmq.tag:monitoring` и `lavinmq.tag:administrator`
+с понятными названиями, затем назначьте их разным политикам. В конфигурации брокера
+оставьте `mgmt_scopes = openid profile email`, а `audience` и `resource_server_id`
+укажите равными Audience приложения (например, `lavinmq`). Права приходят из
+`resource_access.lavinmq.roles`; перечислять их в общих `mgmt_scopes` не нужно.
+`preferred_username_claims = email,sub` показывает почту вместо UUID.
+
Разрешение HTTP для localhost относится к этому примеру. Публичная установка
требует HTTPS и корректных issuer, origins и redirect URI.
diff --git a/packages/contracts/locales/ui.en.json b/packages/contracts/locales/ui.en.json
index b0b0724..aa05470 100644
--- a/packages/contracts/locales/ui.en.json
+++ b/packages/contracts/locales/ui.en.json
@@ -2139,5 +2139,29 @@
"copy.1075e4cf": "Create a regular collection for the related records first.",
"copy.699766bc": "Select one record. Deleting the related record clears this field and preserves the system record.",
"copy.b6c0f68c": "Custom user fields",
- "copy.85762b9e": "Display name"
+ "copy.85762b9e": "Display name",
+ "copy.7bdd9d30": "Loading applications…",
+ "copy.25c320f0": "Enable policy-managed access in the OAuth application settings to assign access here.",
+ "copy.0c17f3be": "Permissions combine with the user’s other policies. Changes apply at the next sign-in; issued tokens last up to 5 minutes.",
+ "copy.7bde7f2e": "Allow sign-in",
+ "copy.949fda84": "Application disabled · sign-in unavailable",
+ "copy.bd9a14bf": "Allow sign-in to {{value0}}",
+ "copy.cad56fa2": "Sign-in and profile only. No additional permissions are configured.",
+ "copy.f13b4b7a": "Manage access through policies",
+ "copy.b1cddd54": "The application will appear in policies. Users receive sign-in access and permissions through their policies.",
+ "copy.11935362": "After saving, assign the application to at least one policy. Without a policy grant, sign-in is denied, including for superusers.",
+ "copy.5d44d9ae": "Request only openid profile email in the service. Personal permissions are sent in the JWT: resource_access → audience → roles. For LavinMQ use mgmt_scopes = openid profile email.",
+ "copy.ec0bed95": "Application permissions",
+ "copy.67909c87": "Available permissions. Users receive only the permissions assigned through their policies.",
+ "copy.1a56a5db": "The service can request these permissions for any user allowed to sign in.",
+ "copy.3cda2b35": "Permission {{value0}} name",
+ "copy.4dfdb331": "For example, Monitoring",
+ "copy.dec046d9": "Permission {{value0}} value",
+ "copy.08855bad": "Remove permission {{value0}}",
+ "copy.e5af58a9": "Add permission",
+ "copy.b8eff8a5": "Permission values must be unique",
+ "copy.af48c9cc": "Applications",
+ "copy.280e2273": "Through policies",
+ "copy.45715fc3": "Permissions have changed. Start signing in to the application again.",
+ "copy.be33e6e0": "Users · system"
}
diff --git a/packages/contracts/locales/ui.ru.json b/packages/contracts/locales/ui.ru.json
index 4815cdf..ac17dab 100644
--- a/packages/contracts/locales/ui.ru.json
+++ b/packages/contracts/locales/ui.ru.json
@@ -2139,5 +2139,29 @@
"copy.1075e4cf": "Сначала создайте обычную коллекцию для связанных записей.",
"copy.699766bc": "Выбор одной записи. Если связанную запись удалить, поле очистится. Системная запись останется.",
"copy.b6c0f68c": "Дополнительные поля пользователя",
- "copy.85762b9e": "Отображаемое название"
+ "copy.85762b9e": "Отображаемое название",
+ "copy.7bdd9d30": "Загружаем приложения…",
+ "copy.25c320f0": "Включите управление через политики в настройках OAuth-приложения, чтобы назначить ему доступ здесь.",
+ "copy.0c17f3be": "Разрешения складываются с другими политиками пользователя. Изменения применяются при новом входе; выданный токен действует до 5 минут.",
+ "copy.7bde7f2e": "Разрешить вход",
+ "copy.949fda84": "Приложение выключено · вход недоступен",
+ "copy.bd9a14bf": "Разрешить вход в {{value0}}",
+ "copy.cad56fa2": "Только вход и передача профиля. Дополнительные разрешения не настроены.",
+ "copy.f13b4b7a": "Управлять доступом через политики",
+ "copy.b1cddd54": "Приложение появится в политиках. Вход и разрешения назначаются пользователям через их политики.",
+ "copy.11935362": "После сохранения назначьте приложение хотя бы одной политике. Без разрешения в политике вход закрыт, включая суперпользователей.",
+ "copy.5d44d9ae": "В сервисе запрашивайте только openid profile email. Персональные права передаются в JWT: resource_access → аудитория → roles. Для LavinMQ это mgmt_scopes = openid profile email.",
+ "copy.ec0bed95": "Разрешения приложения",
+ "copy.67909c87": "Каталог доступных прав. Пользователь получает только то, что назначено ему в политиках.",
+ "copy.1a56a5db": "Сервис может запросить эти права для любого пользователя, которому разрешён вход.",
+ "copy.3cda2b35": "Название разрешения {{value0}}",
+ "copy.4dfdb331": "Например, Мониторинг",
+ "copy.dec046d9": "Значение разрешения {{value0}}",
+ "copy.08855bad": "Удалить разрешение {{value0}}",
+ "copy.e5af58a9": "Добавить разрешение",
+ "copy.b8eff8a5": "Значения разрешений не должны повторяться",
+ "copy.af48c9cc": "Приложения",
+ "copy.280e2273": "Через политики",
+ "copy.45715fc3": "Разрешения изменились. Откройте вход в приложение заново.",
+ "copy.be33e6e0": "Пользователи · системная"
}
diff --git a/scripts/docs/browser-auth-contract.mjs b/scripts/docs/browser-auth-contract.mjs
index 0a2a72f..0cd9ec3 100644
--- a/scripts/docs/browser-auth-contract.mjs
+++ b/scripts/docs/browser-auth-contract.mjs
@@ -146,6 +146,13 @@ export function browserAuthContract(key) {
approve: { type: "boolean" },
userId: string,
reuse: { type: "boolean" },
+ serviceScopes: {
+ type: "array",
+ maxItems: 30,
+ items: { type: "string", maxLength: 160 },
+ description:
+ "Displayed servicePermissions.scope values. Required for explicit approval of a policy-managed application. A changed permission set returns 409 and requires a new sign-in.",
+ },
},
required: ["approve", "userId"],
}),
diff --git a/scripts/docs/http-contracts.mjs b/scripts/docs/http-contracts.mjs
index 57f2b7e..29fe188 100644
--- a/scripts/docs/http-contracts.mjs
+++ b/scripts/docs/http-contracts.mjs
@@ -17,6 +17,8 @@ import { monitoringContract } from "./monitoring-contract.mjs";
import { tablePreferencesContract } from "./table-preferences-contract.mjs";
import { userProfileContract } from "./user-profile-contract.mjs";
import { systemCollectionsContract } from "./system-collections-contract.mjs";
+import { oauthPolicyContract } from "./oauth-policy-contract.mjs";
+import { userReferenceContract } from "./user-reference-contract.mjs";
const object = { type: "object", additionalProperties: true };
const json = (schema) => ({ "application/json": { schema } });
const response = (schema, description = "Успех") => ({
@@ -71,6 +73,10 @@ const settingsSectionList = {
};
export function operationContract(key) {
+ const userReference = userReferenceContract(key);
+ if (userReference) return userReference;
+ const oauthPolicy = oauthPolicyContract(key);
+ if (oauthPolicy) return oauthPolicy;
const browser = browserAuthContract(key);
if (browser) return browser;
const systemCollections = systemCollectionsContract(key);
diff --git a/scripts/docs/oauth-policy-contract.mjs b/scripts/docs/oauth-policy-contract.mjs
new file mode 100644
index 0000000..c0bb0f1
--- /dev/null
+++ b/scripts/docs/oauth-policy-contract.mjs
@@ -0,0 +1,121 @@
+const json = (schema) => ({ "application/json": { schema } });
+const string = { type: "string" };
+const strings = { type: "array", items: string };
+const scopes = {
+ ...strings,
+ maxItems: 30,
+ items: { ...string, maxLength: 160 },
+};
+const scopeLabels = {
+ type: "object",
+ additionalProperties: { ...string, maxLength: 120 },
+};
+const catalogApp = {
+ type: "object",
+ required: ["id", "name", "enabled", "audience", "scopes", "scopeLabels"],
+ properties: {
+ id: { ...string, format: "uuid" },
+ name: string,
+ enabled: { type: "boolean" },
+ audience: string,
+ scopes,
+ scopeLabels,
+ },
+};
+const appInput = {
+ type: "object",
+ additionalProperties: false,
+ required: [
+ "name",
+ "description",
+ "enabled",
+ "clientType",
+ "redirectUris",
+ "userIds",
+ "audience",
+ "scopes",
+ ],
+ properties: {
+ name: { ...string, maxLength: 120 },
+ description: { ...string, maxLength: 1000 },
+ enabled: { type: "boolean" },
+ clientType: { enum: ["public", "confidential"] },
+ redirectUris: { ...strings, minItems: 1, maxItems: 10 },
+ userIds: { ...strings, maxItems: 1000 },
+ accessMode: { enum: ["all", "selected", "domains"] },
+ emailDomains: strings,
+ audience: { ...string, maxLength: 200 },
+ scopes,
+ scopeLabels,
+ policyManaged: {
+ type: "boolean",
+ description:
+ "Use assigned policies instead of user/domain rules. Omission preserves the existing value; defaults to false on creation.",
+ },
+ },
+};
+const application = {
+ ...appInput,
+ additionalProperties: true,
+ properties: {
+ ...appInput.properties,
+ id: { ...string, format: "uuid" },
+ createdAt: { ...string, format: "date-time" },
+ },
+};
+const response = (data) => ({
+ description: "Success",
+ content: json({ type: "object", required: ["data"], properties: { data } }),
+});
+const errors = {
+ 400: { description: "Invalid application or permission catalog" },
+ 401: { description: "Authentication required" },
+ 403: { description: "Human identity and settings access required" },
+ default: { description: "Request or server error" },
+};
+
+export function oauthPolicyContract(key) {
+ if (key === "GET /policies/applications") {
+ return {
+ description:
+ "Catalog of policy-managed OAuth applications, including disabled applications. Requires policies/read or policies/update. Contains no client secrets. Catalog permissions do not grant access by themselves.",
+ responses: {
+ 200: response({ type: "array", items: catalogApp }),
+ ...errors,
+ },
+ };
+ }
+ if (
+ !["GET /oauth-apps", "POST /oauth-apps", "PUT /oauth-apps/:id"].includes(
+ key,
+ )
+ )
+ return undefined;
+ const read = key.startsWith("GET ");
+ return {
+ description:
+ "OAuth application configuration. policyManaged defaults to false; omission on update preserves policyManaged and scopeLabels. Catalog scopes require an audience, label keys must belong to scopes. In policy mode authorization requests use openid/profile/email only; personal permissions are emitted as resource_access[audience].roles. Changing audience or policy-management mode resets consent. Existing client type cannot change.",
+ ...(!read
+ ? { requestBody: { required: true, content: json(appInput) } }
+ : {}),
+ responses: {
+ [key.startsWith("POST ") ? 201 : 200]: response(
+ read
+ ? { type: "array", items: application }
+ : {
+ type: "object",
+ required: ["application"],
+ properties: {
+ application,
+ clientSecret: {
+ ...string,
+ description:
+ "Returned once for a new confidential application",
+ },
+ },
+ },
+ ),
+ ...errors,
+ },
+ };
+}
diff --git a/scripts/docs/user-reference-contract.mjs b/scripts/docs/user-reference-contract.mjs
new file mode 100644
index 0000000..2801faf
--- /dev/null
+++ b/scripts/docs/user-reference-contract.mjs
@@ -0,0 +1,73 @@
+export function userReferenceContract(key) {
+ if (key !== "GET /users/references") return undefined;
+ return {
+ description:
+ "Bounded system-user picker. Requires an authenticated human superuser or users section read/update permission. Returns only id and a display label (name/email); service accounts are denied. Search lists active users; explicit ids may resolve disabled users already referenced. This does not expose system tables through /items.",
+ parameters: [
+ { name: "q", in: "query", schema: { type: "string", maxLength: 200 } },
+ {
+ name: "page",
+ in: "query",
+ schema: { type: "integer", minimum: 1, maximum: 10000, default: 1 },
+ },
+ {
+ name: "limit",
+ in: "query",
+ schema: { type: "integer", minimum: 1, maximum: 100, default: 25 },
+ },
+ {
+ name: "ids",
+ in: "query",
+ schema: { type: "string" },
+ description:
+ "Up to 100 comma-separated UUIDs; invalid IDs and unknown query parameters are rejected.",
+ },
+ ],
+ responses: {
+ 200: {
+ description: "User reference page",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ additionalProperties: false,
+ required: ["data", "labels", "page"],
+ properties: {
+ data: {
+ type: "array",
+ items: {
+ type: "object",
+ additionalProperties: false,
+ required: ["id", "label"],
+ properties: {
+ id: { type: "string", format: "uuid" },
+ label: { type: "string" },
+ },
+ },
+ },
+ labels: {
+ type: "object",
+ additionalProperties: { type: "string" },
+ },
+ page: {
+ type: "object",
+ required: ["number", "size", "total", "sort", "direction"],
+ properties: {
+ number: { type: "integer" },
+ size: { type: "integer" },
+ total: { type: "string" },
+ sort: { const: "label" },
+ direction: { const: "asc" },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ 400: { description: "Invalid query" },
+ 401: { description: "Authentication required" },
+ 403: { description: "User directory access denied" },
+ },
+ };
+}
diff --git a/scripts/test.mjs b/scripts/test.mjs
index 2ca93ad..20be1c7 100644
--- a/scripts/test.mjs
+++ b/scripts/test.mjs
@@ -170,7 +170,7 @@ async function main() {
if (suite === "core-browser")
return /test\/(?:browser-|public-server)/.test(name);
if (suite === "core-system-collections") {
- return /test\/(?:system-collections|user-profiles|profile-avatar|files-access|collections|field-configuration|http-access-surface)[-.]/.test(
+ return /test\/(?:system-collections|user-relations|user-profiles|profile-avatar|files-access|collections|field-configuration|http-access-surface)[-.]/.test(
name,
);
}