Skip to content

Evaluate auth().isSystem() (cratestack 0.7.10) against the seven-times-repeated hasRole(system) gap and #155's golden test #176

Description

@stephane-segning

Summary

cratestack 0.7.10 (just bumped in #TBD) ships PR #500, which adds auth().isSystem() as a first-class policy-term builtin — a cratestack_core::SystemContext is now the only structurally-unforgeable way to mint a CoolContext for which is_system() is true, and auth().isSystem() can name that principal directly in a model's @@allow, for both read (list/detail) and write (create/update/delete) policies.

This is worth a deliberate look here, not a drive-by schema edit, because of how much history this exact gap has in vsms.

Why this matters here specifically

AGENTS.md's "Invariants that fail the build rather than production" section documents a single mistake shape found live seven separate times (App, AppClient, SenderIdRegistration, OperatorPrefixRule #94; Provider, Job #100; Message list/detail #96; DeliveryReceipt #121) and explicitly predicted an eighth: a model whose @@allow omits hasRole('system') doesn't error — CrateStack's list-route policy denial is row-level filtering to an empty array, so an internal system-context read just quietly returns nothing and the caller behaves as though the table were empty.

#155 closed the loop on detection with crates/sms-api/tests/system_context_golden_list_live_postgres.rs: a live-Postgres, seed-then-read golden test that (a) derives the full model list from schema/schema.cstack itself so it can't silently drift, (b) keeps a hand-maintained, justified classification (SYSTEM_READABLE_MODELS / NOT_REQUIRED_TO_BE_SYSTEM_READABLE) that fails loudly the moment a new model isn't classified either way, and (c) actually seeds a row and reads it back under a real system-role context against a real database for every model that's supposed to be system-readable. As of #155, all 13 models an internal system context reads today correctly admit hasRole('system'); the other 6 have no internal reader to break.

auth().isSystem() is a candidate to replace hasRole('system') as the mechanism itself, not just another way to spell the same string:

  • hasRole('system') today works because sms-auth's AuthProvider mints a synthetic role: "system" claim for internal callers, and every @@allow clause has to remember to check for it — a convention, not something the type system or CrateStack enforces. auth().isSystem() instead asks whether the CoolContext itself was constructed via SystemContext — per PR #500, "the sole, structurally-unforgeable way to mint a system CoolContext," with an explicit test proving no HTTP request can produce one (http_request_cannot_produce_a_system_context).
  • That closes a strictly worse failure mode than the one No invariant guards the hasRole(system) policy gap — found live seven times, an eighth is expected #155 guards: today, if role: "system" ever leaked into a real issued token (AGENTS.md's M1 section already flags this as the one state that must never happen — "system is synthetic and no issued token may carry it"), hasRole('system') policies would trust it. auth().isSystem() can't be spoofed by a claim at all, only by actually holding a SystemContext value, which only sms-api/sms-worker/sms-auth's own internal code can construct.

What this issue is NOT asking for

Not a schema edit. Not a decision to migrate today. This is a research/decision ticket:

  1. Does adopting auth().isSystem() fully replace hasRole('system') in schema/schema.cstack, or do both need to coexist (e.g. is there a real distinction in this schema between "the request carries a system role claim" and "this is genuinely internal server code" that's worth keeping separate)?
  2. If it's a full replacement: is it a mechanical, one-PR migration (swap every hasRole('system') clause for auth().isSystem(), verify byte-identical DDL per the standing regeneration discipline in AGENTS.md — a policy-only @@allow change never touches DDL), or does it interact with anything non-mechanical (e.g. sms_auth::system_context()'s own construction, Procedures::sys())?
  3. Interaction with No invariant guards the hasRole(system) policy gap — found live seven times, an eighth is expected #155's golden test, specifically: system_context_golden_list_live_postgres.rs currently greps schema/schema.cstack's text for the literal string hasRole('system') in its per-model assertions (see e.g. lines asserting "hasRole('system') is absent from Provider's create policy"). If any model's policy is rewritten to auth().isSystem(), those specific string assertions would need updating to match — the live seed-then-read behavior they're proving stays valid (a SystemContext-backed read should still succeed), but the assertion text checking why it should succeed would go stale and either false-fail or (worse) silently stop asserting the right thing if left unchanged. Whoever picks this up should treat No invariant guards the hasRole(system) policy gap — found live seven times, an eighth is expected #155's test file as something this change touches, not something it can leave alone.
  4. Whether auth().isSystem() is available for procedure-level policies at all — PR #500's own Scope section says explicitly it is not ("Procedure-level policies... #486's scope and the spike both concern model policies only"), so sms_api::require_permission's Layer 2 checks (#24, sms:send etc.) are unaffected either way.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apisms-api, procedures, generated routerdecisionNeeds a human decision, not engineering

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions