Absorb the authz wrapper — and settle the ListObjects type:id footgun #2

Closed
opened 2026-08-06 16:45:50 +00:00 by ginjiruu · 2 comments
Owner

Question

What shape does the absorbed authorization helper take, and where does the ListObjects type:id footgun get fixed?

Both consumers hand-write internal/authz (~140-160 lines): Scope() calls Server.ListObjects and re-attaches the type: prefix in fgaScope.Allows(); Check() delegates; Enforce() writes 403/500 JSON. The example hand-writes the prefix strip in Service.ListBoardIDs. This is duplicated glue the example had to write AND consumers copy — it passes the "example proves it" boundary.

Decisions (settled during grilling)

  1. New toolbelt/authz package (not extending embeddedfga) — embeddedfga stays the raw transport; authz owns the policy layer, mirroring authnembeddedoidc.
  2. type:id strip lives in authz.ScopeScope calls the backend ListObjects, strips the objectType: prefix once, stores bare ids; Allows(bareID) compares bare. embeddedfga stays faithful to OpenFGA's wire contract.
  3. Enforce() absorbed — the byte-identical HTTP 403/500 JSON writer comes into authz.
  4. Both ScopeFunc AND ListRelations absorbed. ScopeFunc is the tiny predicate→Scope adapter; ListRelations is the BatchCheck primitive ("which of these relations hold for user↔object") that powers HATEOAS action computation. HATEOAS as a pattern is its own ticket (see "HATEOAS actions pattern — show only actions the user can take"), but the two primitives belong in authz.
  5. User seam: Scope(ctx, objectType, relation, userRef string) — refs are codegen-owned (fga-generate emits TypeBoard/BoardRef/UserRef), so the wrapper passes through pre-built strings; it never formats refs itself.
  6. No write interface in authz. Tuple writes already ride on embeddedfga.Server (app/component.go:42 exposes it as Component.Authorizer); the example proves that path via projectors. Write seam stays where it is.
  7. Narrow backend interfaceauthz binds to Check(ctx, Tuple) + ListObjects(ctx, objectType, relation, user) (+ BatchCheck for ListRelations). *embeddedfga.OpenFGAServer satisfies it today; the Client doesn't (different signatures) — making backends swappable is the separate "Swappable backends" issue.

The ListRelations surface needs a BatchCheck seam on the backend. ttrpg's implementation reaches into raw openfgav1 (f.Server.Server.BatchCheck, StoreID, AuthorizationModelID). The absorbed version should hide that behind a clean authz API — this is a design decision to resolve while implementing this ticket.

Resolve with /grilling + /domain-modeling; link any prototype API sketch as an asset. On close, write the absorption plan to docs/plans/ and reshape the relevant example/ surface onto the decided shape.

## Question What shape does the absorbed authorization helper take, and where does the `ListObjects` `type:id` footgun get fixed? Both consumers hand-write `internal/authz` (~140-160 lines): `Scope()` calls `Server.ListObjects` and re-attaches the `type:` prefix in `fgaScope.Allows()`; `Check()` delegates; `Enforce()` writes 403/500 JSON. The example hand-writes the prefix strip in `Service.ListBoardIDs`. This is duplicated glue the example had to write AND consumers copy — it passes the "example proves it" boundary. ## Decisions (settled during grilling) 1. **New `toolbelt/authz` package** (not extending `embeddedfga`) — `embeddedfga` stays the raw transport; `authz` owns the policy layer, mirroring `authn` ↔ `embeddedoidc`. 2. **`type:id` strip lives in `authz.Scope`** — `Scope` calls the backend `ListObjects`, strips the `objectType:` prefix once, stores bare ids; `Allows(bareID)` compares bare. `embeddedfga` stays faithful to OpenFGA's wire contract. 3. **`Enforce()` absorbed** — the byte-identical HTTP 403/500 JSON writer comes into `authz`. 4. **Both `ScopeFunc` AND `ListRelations` absorbed.** `ScopeFunc` is the tiny predicate→Scope adapter; `ListRelations` is the BatchCheck primitive ("which of these relations hold for user↔object") that powers HATEOAS action computation. HATEOAS as a *pattern* is its own ticket (see "HATEOAS actions pattern — show only actions the user can take"), but the two primitives belong in `authz`. 5. **User seam: `Scope(ctx, objectType, relation, userRef string)`** — refs are codegen-owned (`fga-generate` emits `TypeBoard`/`BoardRef`/`UserRef`), so the wrapper passes through pre-built strings; it never formats refs itself. 6. **No write interface in `authz`.** Tuple writes already ride on `embeddedfga.Server` (`app/component.go:42` exposes it as `Component.Authorizer`); the example proves that path via projectors. Write seam stays where it is. 7. **Narrow backend interface** — `authz` binds to `Check(ctx, Tuple)` + `ListObjects(ctx, objectType, relation, user)` (+ BatchCheck for `ListRelations`). `*embeddedfga.OpenFGAServer` satisfies it today; the Client doesn't (different signatures) — making backends swappable is the separate "Swappable backends" issue. **The ListRelations surface needs a BatchCheck seam on the backend.** ttrpg's implementation reaches into raw `openfgav1` (`f.Server.Server.BatchCheck`, `StoreID`, `AuthorizationModelID`). The absorbed version should hide that behind a clean `authz` API — this is a design decision to resolve while implementing this ticket. Resolve with `/grilling` + `/domain-modeling`; link any prototype API sketch as an asset. On close, write the absorption plan to `docs/plans/` and reshape the relevant `example/` surface onto the decided shape.
Author
Owner

RESOLVED — design settled via grilling.

Answer: a new toolbelt/authz package owns the policy layer; the type:id strip lives in Scope.

Seven decisions locked:

  1. New toolbelt/authz package (not extending embeddedfga) — embeddedfga stays the raw OpenFGA transport; authz is the policy layer on top, mirroring authnembeddedoidc.
  2. type:id strip in authz.ScopeScope calls the backend ListObjects, strips objectType: once, stores bare ids; Allows(bareID) compares bare. embeddedfga stays faithful to OpenFGA's wire contract. (The footgun is solved at the policy layer where every app already lands.)
  3. Enforce() absorbed — the byte-identical HTTP 403/500 JSON writer.
  4. ScopeFunc AND ListRelations both absorbed — the predicate→Scope adapter and the BatchCheck primitive ("which relations hold for user↔object") that powers HATEOAS. The HATEOAS pattern itself is ticket #11 ("HATEOAS actions pattern — show only actions the user can take").
  5. User seam Scope(ctx, objectType, relation, userRef string) — refs are codegen-owned (fga-generate), so the wrapper passes through pre-built strings; never formats refs.
  6. No write interface in authz — tuple writes ride on embeddedfga.Server (Component.Authorizer, app/component.go:42); the example proves that via projectors.
  7. Narrow backend interfaceauthz binds to Check(ctx, Tuple) + ListObjects(ctx, objectType, relation, user) (+ BatchCheck for ListRelations). *embeddedfga.OpenFGAServer satisfies it today; making the Client swappable is the separate issue #10 ("Swappable backends").

Open design point for the implement pass: the ListRelations BatchCheck seam must hide raw openfgav1 (ttrpg reaches into f.Server.Server.BatchCheck today).

Deliverable (next session): write docs/plans/authz.md and reshape the example/ kanban surface (the inline authorizer interface + ListBoardIDs prefix strip) onto the absorbed authz shape.

**RESOLVED — design settled via grilling.** **Answer: a new `toolbelt/authz` package owns the policy layer; the `type:id` strip lives in `Scope`.** Seven decisions locked: 1. **New `toolbelt/authz` package** (not extending `embeddedfga`) — `embeddedfga` stays the raw OpenFGA transport; `authz` is the policy layer on top, mirroring `authn` ↔ `embeddedoidc`. 2. **`type:id` strip in `authz.Scope`** — `Scope` calls the backend `ListObjects`, strips `objectType:` once, stores bare ids; `Allows(bareID)` compares bare. `embeddedfga` stays faithful to OpenFGA's wire contract. (The footgun is solved at the policy layer where every app already lands.) 3. **`Enforce()` absorbed** — the byte-identical HTTP 403/500 JSON writer. 4. **`ScopeFunc` AND `ListRelations` both absorbed** — the predicate→Scope adapter and the BatchCheck primitive ("which relations hold for user↔object") that powers HATEOAS. The HATEOAS *pattern* itself is ticket #11 ("HATEOAS actions pattern — show only actions the user can take"). 5. **User seam `Scope(ctx, objectType, relation, userRef string)`** — refs are codegen-owned (`fga-generate`), so the wrapper passes through pre-built strings; never formats refs. 6. **No write interface in `authz`** — tuple writes ride on `embeddedfga.Server` (`Component.Authorizer`, app/component.go:42); the example proves that via projectors. 7. **Narrow backend interface** — `authz` binds to `Check(ctx, Tuple)` + `ListObjects(ctx, objectType, relation, user)` (+ BatchCheck for `ListRelations`). `*embeddedfga.OpenFGAServer` satisfies it today; making the Client swappable is the separate issue #10 ("Swappable backends"). **Open design point for the implement pass:** the `ListRelations` BatchCheck seam must hide raw `openfgav1` (ttrpg reaches into `f.Server.Server.BatchCheck` today). **Deliverable (next session):** write `docs/plans/authz.md` and reshape the `example/` kanban surface (the inline `authorizer` interface + `ListBoardIDs` prefix strip) onto the absorbed `authz` shape.
Author
Owner

Design settled: new toolbelt/authz policy package, type:id strip in Scope, ScopeFunc + ListRelations absorbed, Enforce absorbed, no write interface. Deliverable (plan + example reshape) taken to a new session. See resolution comment.

Design settled: new toolbelt/authz policy package, type:id strip in Scope, ScopeFunc + ListRelations absorbed, Enforce absorbed, no write interface. Deliverable (plan + example reshape) taken to a new session. See resolution comment.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ginjiruu/toolbelt#2
No description provided.