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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/core/migrations/20261007030000_oauth_policy_access.cjs
Original file line number Diff line number Diff line change
@@ -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");
});
};
22 changes: 22 additions & 0 deletions apps/core/migrations/20261007040000_system_user_relations.cjs
Original file line number Diff line number Diff line change
@@ -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();
});
};
103 changes: 103 additions & 0 deletions apps/core/src/auth/user-references.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
},
};
}
12 changes: 12 additions & 0 deletions apps/core/src/auth/user-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof requireSettingsSection>[1]) =>
requireSettingsRead(db(), request, "users", "policies"),
Expand Down
6 changes: 3 additions & 3 deletions apps/core/src/collections/catalog-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ export async function listCollections(database: Knex): Promise<Collection[]> {
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
Expand Down
4 changes: 3 additions & 1 deletion apps/core/src/collections/editable-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apps/core/src/collections/field-behavior-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
5 changes: 5 additions & 0 deletions apps/core/src/collections/field-default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
21 changes: 20 additions & 1 deletion apps/core/src/collections/relation-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,28 @@ export function parseCreateRelation(value: unknown): CreateRelationInput {
}
const input = value as Record<string, unknown>;
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"
Expand Down
12 changes: 12 additions & 0 deletions apps/core/src/collections/relation-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ export async function updateRelationSearch(
? parseSearchPriority((body as Record<string, unknown>).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 })
Expand Down
30 changes: 18 additions & 12 deletions apps/core/src/collections/relations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,17 @@ export async function createForeignKey(
): Promise<void> {
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}`,
Expand Down Expand Up @@ -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,
],
);
Expand All @@ -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")
Expand Down
Loading