Skip to content

Commit a493fcd

Browse files
Use native Interchange authorization for artifacts (#5)
* Use Interchange grants for artifact authorization * Demonstrate native artifact authorization * Document native artifact authorization * Restore ownership-denial coverage with a real grant evaluator Adds a host grant-provisioning seam (onArtifactCreated) and wires it in the reference host to mint a real creator-origin grant on artifact creation, checked through the platform's own createRequireGrant/authorize rather than a default-allow stub. Unit tests run the same routes through createInMemoryGrantStore so a non-owner is refused on the merits, not by a bare test predicate; acceptance tests prove the same end to end against a real Postgres-backed grant table. Cross-tenant tool reads stay removed: Interchange's GrantStore resolves grants within one tenant, so there is no platform primitive to check a cross-tenant grant against. Recorded explicitly in CHANGELOG/ARCHITECTURE rather than left as an unexplained gap. * Check existence before grants on single-artifact write routes A real grant evaluator has no existence check of its own: it denies a ghost id or another tenant's artifact with the same 403 it gives a real row the caller lacks permission on. Switching the reference host off a default-allow stub surfaced this — write routes ran requireGrant before loadScoped, so CI caught cross-tenant/skill-draft ids answering 403 instead of the documented 404. Adds an artifactExists middleware that resolves existence/tenant/ skill-draft ahead of requireGrant on the three single-artifact write routes, so a caller who cannot see the row still gets 404 regardless of what the grant evaluator would have said.
1 parent 6447b43 commit a493fcd

20 files changed

Lines changed: 888 additions & 625 deletions

‎ARCHITECTURE.md‎

Lines changed: 91 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -24,63 +24,109 @@ Interchange serves its own routes under (`app.route("/api/me", …)`,
2424
`app.route("/api/tenants", …)`). No `/v1` segment, no vendor prefix.
2525

2626
```ts
27-
const api = new Hono<AppEnv>();
28-
mountArtifacts(api, { db, contentStore, resolvePrincipal });
27+
const api = new Hono<TenantEnv>();
28+
// Host middleware has already placed `tenant` and `principal` on the context.
29+
mountArtifacts(api, { db, contentStore, requireGrant });
2930
app.route("/api", api);
3031
```
3132

3233
which serves `/api/artifacts`, `/api/artifacts/:id`,
3334
`/api/artifacts/:id/versions`, `/api/artifacts/:id/download`, and
3435
`/api/instances/:instanceId/mail-attachments`. Nesting rather than teaching the
35-
core a base path keeps the frozen `mountX<E extends Env>(app, opts) => Hono<E>`
36-
seam untouched.
36+
core a base path keeps the mount free of a configurable base path.
3737

38-
Everything else it needs arrives through `opts`. Nothing is reached for.
38+
Everything else it needs arrives through `opts` or the host's request context.
39+
Nothing is reached for.
3940

4041
## The mount seam
4142

42-
`mountArtifacts<E extends Env>(app: Hono<E>, opts): Hono<E>` is generic over the
43-
host's Hono `Env`, so it composes with an app that carries its own environment
44-
rather than requiring a bare `Hono`.
45-
46-
Three options have no sensible default — `db`, `contentStore`,
47-
`resolvePrincipal` — and the rest degrade a *feature*, never safety, when
48-
omitted. The README's table of what a minimal host passes is the reference; what
49-
matters architecturally is that every default **fails closed**: no `isAdmin`
50-
means nobody is an admin, no `identity` means no directory and no cross-tenant
51-
reads, no `decorate` means no decoration.
52-
53-
What the package does **not** require of a host: no auth middleware, no session
54-
library, no UI. What it DOES require: Interchange's control plane —
55-
`public.tenant` and `public.principal` must exist before the migrations run,
56-
because the tables carry hard foreign keys into them.
57-
58-
`resolvePrincipal`'s signature is identical across the Corbits cores, so a host
59-
mounting more than one passes the same function to each. The resolved tenant is
60-
authoritative — there is no caller-supplied tenant override anywhere in the
61-
route surface.
43+
`mountArtifacts(app: Hono<TenantEnv>, opts): Hono<TenantEnv>` takes Interchange's
44+
`TenantEnv` so it composes with a host app mounted beneath Interchange auth +
45+
tenant middleware. The host places full `tenant` and `principal` rows on the
46+
context; this package reads them natively and never invents a second principal
47+
resolution path.
48+
49+
Three options have no sensible default — `db`, `contentStore`, `requireGrant` —
50+
and the rest degrade a *feature*, never safety, when omitted. The README's table
51+
of what a minimal host passes is the reference; what matters architecturally is
52+
that optional seams **fail closed**: no `decorate` means no decoration.
53+
54+
What the package does **not** require of a host: no session library, no UI, no
55+
directory, no owner/admin policy callback. What it DOES require: Interchange's
56+
control plane — `public.tenant` and `public.principal` must exist before the
57+
migrations run, because the tables carry hard foreign keys into them — and a
58+
host that puts the authenticated principal on `TenantEnv` and hands in its
59+
`RequireGrant`.
60+
61+
The principal's tenant is authoritative — there is no caller-supplied tenant
62+
override anywhere in the route or tool surface. Tool reads always stay inside
63+
`scope.tenantId`.
64+
65+
**This is a decision, not an oversight.** The prior `Identity` port let
66+
`readArtifact` / `readArtifactChunk` take a `tenantId` argument and cross into
67+
it when `identity.ownerIsMemberOfTenant(scope, tenantId)` said the caller's
68+
owner belonged there — a membership check this package invented and owned.
69+
That is exactly the kind of policy this PR removes. It is not replaced by a
70+
grant check, and won't be by a later one either: Interchange's `GrantStore`
71+
resolves a principal's grants **within one tenant**
72+
(`collectGrants(principalId, tenantId)`; `@intx/db`'s implementation filters
73+
`grant` rows by `tenant_id`, and a principal is itself a row scoped to one
74+
tenant). There is no platform primitive for "principal P, home tenant A, holds
75+
a grant readable from tenant B" to check — inventing one here would mean this
76+
package building a second, bespoke cross-tenant authorization concept on top
77+
of the platform's, which is the precise failure mode "authorization is the
78+
host's job" is meant to prevent. If a real product need for cross-tenant
79+
artifact reads shows up, it belongs in Interchange's grant model, not
80+
re-derived per package.
81+
82+
## Three custom seams
83+
84+
Beyond the host's native context and grants, this package exposes **three**
85+
extension seams: the substrate (`ContentStore`), a display-only decorator
86+
(`decorate` / provenance), and a grant-provisioning hook (`onArtifactCreated`).
87+
Authorization is not a custom seam — it is the host's Interchange `RequireGrant`;
88+
`onArtifactCreated` is not authorization either, it is the write side of the
89+
same idea — the host deciding what makes its grant model true, this package
90+
only handing it the row and the scope that made it.
6291

6392
## The options
6493

65-
`ContentStore` and `Identity` are types declared in `ports.ts`; the rest are
66-
plain `mountArtifacts` options. `resolvePrincipal` takes the host's request
67-
context as `unknown`.
94+
`ContentStore` is declared in `ports.ts`; the rest are plain `mountArtifacts`
95+
options. Who the request runs as is read from `TenantEnv`, not passed as a
96+
callback.
6897

6998
| Option | What it is for | Default |
7099
| --- | --- | --- |
71-
| `resolvePrincipal` | Who the request runs as. Reads the host session; returns `null` when signed out. | none — required |
100+
| `requireGrant` | Host-owned Interchange grant middleware factory. Single-artifact mutations (revise, archive/unarchive, …) run `requireGrant(idResource("artifact", "id"), <action>)`. | none — required |
72101
| `contentStore` | Where an artifact's file bytes live (`ContentStore`). | none — required |
73-
| `isAdmin` | Whether a principal is a tenant admin. Only archive/unarchive consults it. | nobody is an admin |
74-
| `identity` | Owner display names, the agent→human ownership resolution, creator-kind principal sets, and cross-tenant membership (`Identity`). | `anonymousIdentity` |
75-
| `decorate` | A **display-only** decorator over serialized rows. | no-op |
102+
| `decorate` | A **display-only** decorator over serialized rows (provenance labels, host joins). | no-op |
103+
| `onArtifactCreated` | Host hook run inside the same transaction as artifact creation — where a host mints grants for the row it just made. | no-op |
76104

77105
`decorate`'s display-only status is a contract, not a convention: it may add
78106
fields to rows on their way out and must never affect *what* is returned or
79107
*who* may see it. Joining a host's workflow tables inside this package would
80108
couple it to a schema it must not know, so the host supplies the decorator.
81-
82-
`Identity.ownerIsMemberOfTenant` gates cross-tenant reads and must fail closed;
83-
the shipped `anonymousIdentity` does.
109+
Clients that need an owner display name resolve `ownerPrincipalId` themselves;
110+
this package never ships directory names on the wire.
111+
112+
### Grant provisioning (`onArtifactCreated`)
113+
114+
Checking a grant (`requireGrant`) and minting one (`onArtifactCreated`) are the
115+
same host responsibility looked at from both ends: this package neither
116+
invents authorization policy nor decides who a newly created row belongs to
117+
for grant purposes — it hands the host the row, inside the transaction that
118+
made it durable, and the host decides.
119+
120+
`examples/reference-host` provisions a real `creator`-origin grant on create —
121+
`write` and `archive` on `artifact:<id>` for the creating principal, inserted
122+
into Interchange's own `grant` table via `@intx/db`'s schema, in the same
123+
transaction as the artifact row. Its `buildApp`'s default `requireGrant` is the
124+
platform's real `createRequireGrant` over that same table (via
125+
`createGrantStore`), not a stub — a principal with no matching row is refused,
126+
exactly as in production. See `grantOwnership` in
127+
`examples/reference-host/src/index.ts` and the "ownership-derived grants"
128+
scenarios in its acceptance suite for the end-to-end proof: the creator
129+
succeeds, a co-tenant with no grant does not.
84130

85131
### ContentStore
86132

@@ -107,15 +153,15 @@ which store is installed.
107153

108154
| File | Role |
109155
| --- | --- |
110-
| `mount.ts` | HTTP surface: parsing, validation, status codes. |
156+
| `mount.ts` | HTTP surface: parsing, validation, status codes; reads `TenantEnv` principal; wires host `requireGrant`. |
111157
| `artifacts.ts` | The core domain — create, revise, list, get, archive, serialize. |
112158
| `uploads.ts` | `createFileArtifact`, the MIME policies, and the size caps. |
113159
| `download.ts` | One download path over the three storage conventions. |
114160
| `content-store.ts` | The two shipped `ContentStore` implementations. |
115-
| `tools.ts` | Agent-facing tool definitions and windowed artifact reads. |
161+
| `tools.ts` | Agent-facing tool definitions and windowed artifact reads (caller tenant only). |
116162
| `web-site.ts` | The `web-site` kind's content encoding and validation. |
117163
| `mail-attachments.ts` | Artifact↔message associations. |
118-
| `ports.ts` | The `ContentStore` and `Identity` types, and the fail-closed `anonymousIdentity` default. |
164+
| `ports.ts` | The `ContentStore` type and the shared `ResolvedPrincipal` shape. |
119165
| `schema.ts` / `migrations.ts` | The four tables, and the DDL that creates them. |
120166

121167
## Data model
@@ -140,10 +186,10 @@ single-column constraints applied by a ledgered migration — free at write time
140186
the control plane independently; it does **not** enforce that `principal_id` (or
141187
`owner_principal_id`) belongs to the same tenant as `tenant_id`. A multi-table
142188
trigger or composite FK into `public.principal` would couple every write to a
143-
control-plane lookup and is deliberately out of scope. The host's
144-
`resolvePrincipal` is the authority: it returns the `(tenantId, principalId)`
145-
pair every route and tool write stamps, so a correctly mounted host never
146-
plants a cross-tenant principal. Operators cleaning legacy rows before the
189+
control-plane lookup and is deliberately out of scope. The host's middleware and
190+
context are the authority: routes stamp the `(tenantId, principalId)` pair from
191+
the Interchange `principal` already on `TenantEnv`, so a correctly mounted host
192+
never plants a cross-tenant principal. Operators cleaning legacy rows before the
147193
`tenant_id NOT NULL` migration must assign a valid tenant or delete orphans —
148194
the migration fails with an explicit message if null `tenant_id` rows remain.
149195

@@ -234,9 +280,9 @@ upload **gate** (`createFileArtifact` takes `policy` as a required argument and
234280
refuses anything outside it before the `ContentStore` is touched); and the
235281
download path with its `nosniff`/`attachment` behaviour.
236282

237-
Supplied by the host: the Hono app and the database handle; who the caller is;
238-
whether they are an admin; the directory, if there is one; provenance
239-
decoration; and a `ContentStore`.
283+
Supplied by the host: the `Hono<TenantEnv>` app and the database handle; the
284+
authenticated `tenant`/`principal` on the request context; the host's
285+
`RequireGrant`; display-only provenance decoration; and a `ContentStore`.
240286

241287
## Known limits
242288

‎CHANGELOG.md‎

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,47 @@ always called out under their own heading.
1616
`bun add github:corbitsdev/corbits-artifacts` installs cleanly. Bun consumers
1717
resolve TypeScript sources via the `bun` export condition; Node consumers
1818
continue to use the built `dist/` from `npm pack` / a published release.
19+
- `mountArtifacts` takes an optional `onArtifactCreated(tx, row, scope)` hook,
20+
run inside the same transaction as artifact creation (once per row, so once
21+
on `POST /artifacts` and once per file on `POST /artifacts/upload`). This is
22+
the seam a host uses to provision grants for the row it just made — for
23+
example, a `creator`-origin grant on `artifact:<id>` for `write` and
24+
`archive`. Defaults to a no-op, so existing hosts are unaffected.
25+
`examples/reference-host` now wires a real one (`grantOwnership`) against
26+
Interchange's own `grant` table, and its default `requireGrant` is the
27+
platform's real `createRequireGrant` over that table rather than a
28+
default-allow stub — see ARCHITECTURE.md's "Grant provisioning" section.
29+
- Single-artifact write routes (`POST .../versions`, `POST .../archive`,
30+
`POST .../unarchive`) now resolve existence/tenant/skill-draft (the same
31+
check `loadScoped` does) BEFORE running `requireGrant`, not after. A real,
32+
resource-specific grant evaluator has no existence check of its own — it
33+
denies a ghost id or another tenant's artifact with the same `403` it would
34+
give for a real row the caller lacks permission on, which a default-allow
35+
stub can never surface. This restores the documented "a caller who cannot
36+
see the artifact gets 404" guarantee for write routes running a real grant
37+
check, matching what already held for reads.
38+
39+
### Breaking
40+
41+
- `mountArtifacts` takes `Hono<TenantEnv>`, reads the host-provided tenant and
42+
principal context natively, and requires the host's Interchange `RequireGrant`
43+
middleware. The `resolvePrincipal`, `isAdmin`, and `identity` options and the
44+
`Identity` / `anonymousIdentity` exports are not part of the package surface.
45+
- Serialized artifact rows expose `ownerPrincipalId` without an `ownerName`.
46+
Artifact lists no longer accept `creatorKind`.
47+
- **Cross-tenant tool reads are removed, intentionally, not just undocumented.**
48+
`readArtifact` / `readArtifactChunk` no longer take a `tenantId` override;
49+
tool reads are always confined to `scope.tenantId`. The prior override read
50+
through `Identity.ownerIsMemberOfTenant`, a membership policy this package
51+
invented and owned — exactly what this PR removes. It is not replaced by a
52+
grant check because there is no platform primitive to replace it with:
53+
Interchange's `GrantStore` resolves a principal's grants within one tenant
54+
(a principal is itself a row scoped to one tenant), so "grant readable
55+
across tenants" does not exist to check. Reintroducing cross-tenant reads
56+
here would mean this package inventing a second, bespoke cross-tenant
57+
authorization concept on top of the platform's — the failure mode this PR
58+
exists to remove. If a real need for it surfaces, it belongs in
59+
Interchange's grant model, not a per-package workaround.
1960

2061
### 0.1.0 — first release
2162

@@ -24,10 +65,10 @@ new; the list below is what the surface consists of rather than what changed.
2465

2566
- `mountArtifacts(app, opts)` — mounts artifacts, versions and uploads on a
2667
host's existing Hono app: tenant-scoped list with keyset paging and
27-
query/kind/owner/creator-kind/date filters, human import of a link or pasted
28-
text, multipart upload, deep-link detail, version history and revision,
29-
idempotent soft archive and unarchive, a single download path, and
30-
artifact↔message attachment refs. Every route carries OpenAPI metadata.
68+
query/kind/owner/date filters, human import of a link or pasted text,
69+
multipart upload, deep-link detail, version history and revision, idempotent
70+
soft archive and unarchive, a single download path, and artifact↔message
71+
attachment refs. Every route carries OpenAPI metadata.
3172
- `runArtifactMigrations(db)` — idempotent, advisory-locked, checksum-guarded,
3273
with its own ledger table (`artifacts.migrations`) and silent re-runs. Safe to
3374
call on every boot of every replica. All tables live in the package-owned
@@ -38,10 +79,10 @@ new; the list below is what the surface consists of rather than what changed.
3879
- `ContentStore` port with two shipped implementations, `InlineContentStore`
3980
(bytea side-table) and `DataUrlContentStore` (inline `data:` URL), both
4081
passing the same suite.
41-
- Host options: `resolvePrincipal`, plus `isAdmin`, `identity` and
42-
`decorate`, each with a fail-closed default.
43-
- Agent-facing tool definitions with windowed artifact reads, and the `web_site`
44-
artifact kind.
82+
- Host options: required `db`, `contentStore`, and `requireGrant`, plus optional
83+
display-only `decorate` and `uploadPolicy` behavior.
84+
- Agent-facing tool definitions with tenant-confined windowed artifact reads,
85+
and the `web_site` artifact kind.
4586
- Requires `@intx/*` 0.2.2 or newer, Node 22+ or Bun 1.1+, and Postgres 13+.
4687
(`@intx/*` 0.1.2 does not install — its deps pin the unpublished
4788
`@intx/*@0.0.0` — and ships raw TypeScript.)

0 commit comments

Comments
 (0)