Skip to content

Commit e6dba84

Browse files
committed
Delete the identity seam: AdminAuthz replaces Identity, creatorKind is a plain column
Identity resolved owner display names, agent-to-member ownership, and cross-tenant membership through a host directory lookup that a host could skip and silently get zero rows back for. AdminAuthz now answers two fail-closed verdicts directly (canAdminister, canReadTenant), ownerName is gone from the serialized row, and creatorKind is set once at write time from the caller's own scope and read back as a denormalized column predicate — nothing left to resolve, so nothing left to skip.
1 parent 78bc371 commit e6dba84

19 files changed

Lines changed: 264 additions & 279 deletions

‎examples/reference-host/src/index.ts‎

Lines changed: 63 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
// The host is the real thing: hub routes, the hub request logger and the hub
55
// session middleware are all live, and the artifact principal is resolved out
66
// of the hub's own request context (`c.var.user`) rather than a local variable.
7-
// The three seams are implemented against the host's OWN control plane
8-
// (interchange `principal` / `user` rows), which is the point: the module knows
9-
// nothing about them, the host supplies them.
7+
// The two seams (authz + provenance) are implemented against the host's OWN
8+
// control plane (interchange `principal` / `user` rows), which is the point:
9+
// the module knows nothing about them, the host supplies them.
1010
//
1111
// This module only BUILDS the host. The acceptance scenarios live in
1212
// `test/acceptance.test.ts` and run under `bun test`, so they are collected by
1313
// CI like any other test instead of being a hand-rolled assert script nothing
1414
// executes.
15-
import { and, eq, inArray, sql } from "drizzle-orm";
15+
import { and, eq, sql } from "drizzle-orm";
1616
import { Hono } from "hono";
1717
import type { Context } from "hono";
1818
import { createApp, type AppEnv } from "@intx/hub-api";
@@ -30,10 +30,10 @@ import {
3030
InlineContentStore,
3131
mountArtifacts,
3232
runArtifactMigrations,
33+
type AdminAuthz,
3334
type ArtifactDb,
3435
type ResolvedPrincipal,
3536
type ContentStore,
36-
type Identity,
3737
type Provenance,
3838
} from "@corbits/artifact-core";
3939

@@ -54,76 +54,67 @@ function parsePostgresUrl(raw: string) {
5454
};
5555
}
5656

57-
/** Seam B, implemented against the host's own directory tables. */
58-
function createIdentity(db: ArtifactDb): Identity {
59-
return {
60-
async ownerNames(tenantId, ownerPrincipalIds) {
61-
const principals = await db
62-
.select({ id: intxSchema.principal.id, refId: intxSchema.principal.refId })
63-
.from(intxSchema.principal)
64-
.where(
65-
and(
66-
eq(intxSchema.principal.tenantId, tenantId),
67-
inArray(intxSchema.principal.id, ownerPrincipalIds),
68-
),
69-
);
70-
const refIds = [...new Set(principals.map((p) => p.refId))];
71-
const users =
72-
refIds.length > 0
73-
? await db
74-
.select({ id: intxSchema.user.id, name: intxSchema.user.name })
75-
.from(intxSchema.user)
76-
.where(inArray(intxSchema.user.id, refIds))
77-
: [];
78-
const nameByRefId = new Map(users.map((u) => [u.id, u.name]));
79-
return new Map(principals.map((p) => [p.id, nameByRefId.get(p.refId) ?? null]));
80-
},
81-
82-
async ownerMemberPrincipalId(scope) {
83-
// An agent principal's refId names the human who owns it in this host.
84-
const [agent] = await db
85-
.select({ refId: intxSchema.principal.refId })
86-
.from(intxSchema.principal)
87-
.where(
88-
and(
89-
eq(intxSchema.principal.id, scope.principal),
90-
eq(intxSchema.principal.tenantId, scope.tenant),
91-
eq(intxSchema.principal.kind, "agent"),
92-
),
93-
)
94-
.limit(1);
95-
if (!agent) return null;
96-
const [member] = await db
97-
.select({ id: intxSchema.principal.id })
98-
.from(intxSchema.principal)
99-
.where(
100-
and(
101-
eq(intxSchema.principal.tenantId, scope.tenant),
102-
eq(intxSchema.principal.kind, "user"),
103-
eq(intxSchema.principal.refId, agent.refId),
104-
eq(intxSchema.principal.status, "active"),
105-
),
106-
)
107-
.limit(1);
108-
return member?.id ?? null;
109-
},
57+
/**
58+
* Resolve the human member who owns an agent principal — the agent's `refId`
59+
* names them in this host's own `principal` table. Used only by
60+
* `canAdminister`, which is called after the exact-owner match already
61+
* failed, so an unowned or non-agent `ownerPrincipalId` correctly resolves to
62+
* "nobody".
63+
*/
64+
async function ownerMemberPrincipalId(
65+
db: ArtifactDb,
66+
tenantId: string,
67+
agentPrincipalId: string,
68+
): Promise<string | null> {
69+
const [agent] = await db
70+
.select({ refId: intxSchema.principal.refId })
71+
.from(intxSchema.principal)
72+
.where(
73+
and(
74+
eq(intxSchema.principal.id, agentPrincipalId),
75+
eq(intxSchema.principal.tenantId, tenantId),
76+
eq(intxSchema.principal.kind, "agent"),
77+
),
78+
)
79+
.limit(1);
80+
if (!agent) return null;
81+
const [member] = await db
82+
.select({ id: intxSchema.principal.id })
83+
.from(intxSchema.principal)
84+
.where(
85+
and(
86+
eq(intxSchema.principal.tenantId, tenantId),
87+
eq(intxSchema.principal.kind, "user"),
88+
eq(intxSchema.principal.refId, agent.refId),
89+
eq(intxSchema.principal.status, "active"),
90+
),
91+
)
92+
.limit(1);
93+
return member?.id ?? null;
94+
}
11095

111-
async principalIdsByKind(tenantId, kind) {
112-
const rows = await db
113-
.select({ id: intxSchema.principal.id })
114-
.from(intxSchema.principal)
115-
.where(
116-
and(
117-
eq(intxSchema.principal.tenantId, tenantId),
118-
eq(intxSchema.principal.kind, kind),
119-
),
120-
);
121-
return rows.map((r) => r.id);
96+
/**
97+
* Seam A, implemented honestly against the host's own control plane.
98+
* `isAdmin` is the host's own coarse admin check; `canAdminister` folds it
99+
* together with "the member who owns the producing agent may administer its
100+
* artifact" — the core calls this only after the exact-owner match already
101+
* failed, so neither branch needs to re-check ownership.
102+
*/
103+
function createAdminAuthz(db: ArtifactDb, isAdmin: () => Promise<boolean>): AdminAuthz {
104+
return {
105+
async canAdminister(scope, row) {
106+
if (await isAdmin()) return true;
107+
if (row.ownerPrincipalId === null) return false;
108+
const member = await ownerMemberPrincipalId(
109+
db,
110+
scope.tenant,
111+
row.ownerPrincipalId,
112+
);
113+
return member !== null && member === scope.principal;
122114
},
123-
124115
// This host has exactly one tenant, so a cross-tenant read is always
125116
// refused. A multi-tenant host would check active membership there.
126-
async ownerIsMemberOfTenant() {
117+
async canReadTenant() {
127118
return false;
128119
},
129120
};
@@ -308,8 +299,6 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
308299
endSession: refuse("endSession"),
309300
};
310301

311-
const identity = createIdentity(db);
312-
313302
/** Build a host app with one ContentStore backend mounted. */
314303
function buildApp(contentStore: ContentStore, isAdmin: () => Promise<boolean>) {
315304
const app = createApp({
@@ -333,8 +322,7 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
333322
db,
334323
contentStore,
335324
resolvePrincipal,
336-
adminAuthz: { isAdmin },
337-
identity,
325+
adminAuthz: createAdminAuthz(db, isAdmin),
338326
provenance,
339327
});
340328
const mounted = app.route("/api", api);

‎examples/reference-host/test/acceptance.test.ts‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ describe("import a URL, read it back, revise it, read the history", () => {
5757
artifactId = created.artifact.id;
5858
});
5959

60-
test("the identity seam resolves the owner's name from the host directory", async () => {
60+
test("the detail route serves back the artifact with no ownerName field", async () => {
6161
const detail = await json<{
62-
artifact: { ownerName: string | null; source: Record<string, unknown> };
62+
artifact: { source: Record<string, unknown> };
6363
}>(await host.request(`/api/artifacts/${artifactId}`));
64-
expect(detail.artifact.ownerName).toBe("Alice Ash");
64+
expect("ownerName" in detail.artifact).toBe(false);
6565
expect(detail.artifact.source.origin).toBe("imported");
6666
});
6767

@@ -192,13 +192,13 @@ describe("list: keyset paging, creatorKind, and the archived toggle", () => {
192192
expect(overlap).toEqual([]);
193193
});
194194

195-
test("creatorKind resolves through the identity seam", async () => {
195+
test("creatorKind reads the denormalized column, set once at write time", async () => {
196196
// An agent-owned artifact, so creatorKind has something to separate.
197197
await host.db.execute(sql`
198198
INSERT INTO "artifact" ("tenant_id", "principal_id", "owner_principal_id",
199-
"kind", "title", "content", "source", "version")
200-
VALUES (${host.tenant}, ${host.agentPrincipal}, ${host.agentPrincipal}, 'document',
201-
'Agent memo', 'written by an agent', '{"origin":"agent"}'::jsonb, 1)
199+
"creator_kind", "kind", "title", "content", "source", "version")
200+
VALUES (${host.tenant}, ${host.agentPrincipal}, ${host.agentPrincipal}, 'agent',
201+
'document', 'Agent memo', 'written by an agent', '{"origin":"agent"}'::jsonb, 1)
202202
`);
203203

204204
const agentOnly = await json<{ artifacts: { title: string }[] }>(
@@ -313,8 +313,8 @@ describe("a cross-tenant request fails closed", () => {
313313
test("a caller-supplied tenantId is not an override", async () => {
314314
await host.db.execute(sql`
315315
INSERT INTO "artifact" ("tenant_id", "principal_id", "owner_principal_id",
316-
"kind", "title", "content", "source", "version")
317-
VALUES ('some-other-tenant', 'outsider', 'outsider', 'document',
316+
"creator_kind", "kind", "title", "content", "source", "version")
317+
VALUES ('some-other-tenant', 'outsider', 'outsider', 'user', 'document',
318318
'Other tenant secret', 'not yours', '{"origin":"manual"}'::jsonb, 1)
319319
`);
320320
const res = await host.request("/api/artifacts?tenantId=some-other-tenant&limit=100");
@@ -445,6 +445,7 @@ describe("pdf parsing is the host's, and the module's contract with it holds", (
445445
createFileArtifact(tx, InlineContentStore, {
446446
scope: host.scope(),
447447
ownerPrincipalId: host.scope().principal,
448+
creatorKind: "user",
448449
filename: "report.pdf",
449450
mimeType: "application/pdf",
450451
bytes: PDF,
@@ -474,6 +475,7 @@ describe("pdf parsing is the host's, and the module's contract with it holds", (
474475
createFileArtifact(tx, InlineContentStore, {
475476
scope: host.scope(),
476477
ownerPrincipalId: host.scope().principal,
478+
creatorKind: "user",
477479
filename: "logo.svg",
478480
mimeType: "image/svg+xml",
479481
bytes: new Uint8Array(Buffer.from("<svg/>")),
@@ -490,8 +492,8 @@ describe("a skill-draft is invisible over the mounted host", () => {
490492
test("every detail route answers 404", async () => {
491493
const [draft] = await host.db.execute<{ id: string }>(sql`
492494
INSERT INTO "artifact" ("tenant_id", "principal_id", "owner_principal_id",
493-
"kind", "title", "content", "source", "version")
494-
VALUES (${host.tenant}, 'x', 'x', 'skill-draft', 'Scratch',
495+
"creator_kind", "kind", "title", "content", "source", "version")
496+
VALUES (${host.tenant}, 'x', 'x', 'agent', 'skill-draft', 'Scratch',
495497
'draft body', '{"origin":"agent"}'::jsonb, 1)
496498
RETURNING "id"
497499
`);

‎packages/artifact-core/src/artifacts.ts‎

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
getTableColumns,
77
gte,
88
ilike,
9-
inArray,
109
isNotNull,
1110
isNull,
1211
lte,
@@ -17,7 +16,7 @@ import {
1716
} from "drizzle-orm";
1817
import type { ArtifactDb, ArtifactTx } from "./db.js";
1918
import { artifact, artifactVersion, type ArtifactRow } from "./schema.js";
20-
import type { ResolvedPrincipal, Identity, Provenance } from "./ports.js";
19+
import type { ResolvedPrincipal, Provenance } from "./ports.js";
2120
import {
2221
parseWebSiteContentJson,
2322
serializeWebSiteContent,
@@ -94,7 +93,6 @@ export type SerializedArtifact = {
9493
source: Record<string, unknown> & { origin: string };
9594
version: number;
9695
ownerPrincipalId: string | null;
97-
ownerName: string | null;
9896
archivedAt: string | null;
9997
createdAt: string;
10098
updatedAt: string;
@@ -110,7 +108,6 @@ export function serializeArtifact(row: ArtifactRow): SerializedArtifact {
110108
source: normalizeSource(row.source),
111109
version: row.version,
112110
ownerPrincipalId: row.ownerPrincipalId,
113-
ownerName: null,
114111
archivedAt: row.archivedAt?.toISOString() ?? null,
115112
createdAt: row.createdAt.toISOString(),
116113
updatedAt: row.updatedAt.toISOString(),
@@ -129,6 +126,13 @@ export type CreateArtifactArgs = {
129126
scope: ResolvedPrincipal;
130127
/** The human who owns this artifact; null for agents with no owning member. */
131128
ownerPrincipalId: string | null;
129+
/**
130+
* The kind of the principal MINTING this row (`scope.principal`) — not the
131+
* owner's. The caller always knows this at write time, so it is supplied
132+
* rather than looked up: a host route only ever runs as one kind of caller.
133+
* Drives `?creatorKind` as a plain column predicate.
134+
*/
135+
creatorKind: "user" | "agent";
132136
kind: string;
133137
title: string;
134138
content: string;
@@ -156,6 +160,7 @@ export async function createArtifact(
156160
tenantId: args.scope.tenant,
157161
principalId: args.scope.principal,
158162
ownerPrincipalId: args.ownerPrincipalId,
163+
creatorKind: args.creatorKind,
159164
kind: args.kind,
160165
title: args.title,
161166
content,
@@ -417,7 +422,6 @@ function cursorCondition(raw: string, oldestFirst: boolean): SQL {
417422

418423
export async function listArtifacts(
419424
db: ArtifactDb,
420-
identity: Identity,
421425
tenantId: string,
422426
filters: ListArtifactsFilters,
423427
): Promise<{ rows: ArtifactRow[]; nextCursor: string | null }> {
@@ -450,13 +454,7 @@ export async function listArtifacts(
450454
conditions.push(eq(artifact.ownerPrincipalId, filters.ownerPrincipalId));
451455
}
452456
if (filters.creatorKind) {
453-
// Creator kind is a facet of the owner principal, not a column here, so it
454-
// folds in as an ownerPrincipalId membership test. NO matching principals
455-
// must exclude everything, not fall through to unfiltered.
456-
const ids = await identity.principalIdsByKind(tenantId, filters.creatorKind);
457-
conditions.push(
458-
ids.length > 0 ? inArray(artifact.ownerPrincipalId, ids) : sql`false`,
459-
);
457+
conditions.push(eq(artifact.creatorKind, filters.creatorKind));
460458
}
461459
if (filters.createdAfter !== undefined) {
462460
conditions.push(
@@ -516,25 +514,14 @@ export async function findArtifactByTitle(
516514
}
517515

518516
/**
519-
* Attach owner display names and run the display-only provenance decorator.
520-
* One call so no surface can serialize a row and forget half the enrichment.
517+
* Run the display-only provenance decorator over serialized rows. A named
518+
* call rather than an inline `provenance.decorate(...)` at every call site, so
519+
* a future decoration step has one place to join in.
521520
*/
522521
export async function enrich(
523-
identity: Identity,
524522
provenance: Provenance,
525523
tenantId: string,
526524
rows: SerializedArtifact[],
527525
): Promise<void> {
528-
const ownerIds = [
529-
...new Set(rows.map((r) => r.ownerPrincipalId).filter((id) => id !== null)),
530-
];
531-
if (ownerIds.length > 0) {
532-
const names = await identity.ownerNames(tenantId, ownerIds);
533-
for (const row of rows) {
534-
if (row.ownerPrincipalId !== null) {
535-
row.ownerName = names.get(row.ownerPrincipalId) ?? null;
536-
}
537-
}
538-
}
539526
await provenance.decorate(tenantId, rows);
540527
}

‎packages/artifact-core/src/index.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,12 @@ export type {
2121
MailAttachmentRefRow,
2222
} from "./schema.js";
2323

24-
export { anonymousIdentity, denyAllAdminAuthz, noProvenance } from "./ports.js";
24+
export { denyAllAdminAuthz, noProvenance } from "./ports.js";
2525
export type {
2626
AdminAuthz,
2727
ResolvedPrincipal,
2828
ContentStore,
2929
FileBlob,
30-
Identity,
3130
Provenance,
3231
StoredFile,
3332
} from "./ports.js";

‎packages/artifact-core/src/migrations.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const MIGRATIONS: Migration[] = [
2121
"tenant_id" text,
2222
"principal_id" text,
2323
"owner_principal_id" text,
24+
"creator_kind" text NOT NULL,
2425
"parent_id" text REFERENCES "artifact"("id") ON DELETE CASCADE,
2526
"kind" text NOT NULL,
2627
"title" text NOT NULL,

0 commit comments

Comments
 (0)