feat(auth): define provider-neutral authorization contracts - #144
Draft
djwhitt wants to merge 12 commits into
Draft
feat(auth): define provider-neutral authorization contracts#144djwhitt wants to merge 12 commits into
djwhitt wants to merge 12 commits into
Conversation
Add validated principal and single-tenant context types, a closed capability policy with distinct unauthenticated and forbidden results, and shared Plug/LiveView attachment seams. OIDC identities derive a versioned opaque id from the exact issuer and subject without retaining raw claims or token material. Closes T-229. Refs PL-27.
Namespace local principal identities, separate assertion expiry from stable principals, enforce expiry during policy decisions, and document the structural-validation and pseudonymous-identifier boundaries. Refs T-229 and PL-27.
Fail closed when an OIDC-derived authorization context omits assertion expiry, and strengthen the identity framing regression coverage. Refs T-229 and PL-27.
Reject partial struct-tagged maps without raising through context attachment and authorization paths, and pin the durable Basic-auth principal ID derivation with a compatibility vector. Refs T-229 and PL-27.
Describe the shared Plug and LiveView storage location as a stable assign key rather than implying that it uses conn.private. Refs T-229 and PL-27.
djwhitt
marked this pull request as ready for review
August 15, 2026 15:43
Bring the published authorization-contract layer up to date with origin/main without rewriting its existing history.
…-262) (#166) * Derive compact_max_rows from the compaction engine's memory budget (T-262) A fixed 4Mi-row cap OOMs a 1 GiB compaction engine: the sandbox measured a 4Mi-row group pinning the whole budget during the merge, with preserve_insertion_order already off. A group the engine cannot merge re-plans identically every sweep, so one group blocks its table forever. Splitting the group does not converge — the halves' outputs re-enter the next group up to the same cap. The cap now derives from the budget at 512 bytes per row, half the measured pin rate. The sandbox's 1 GiB budget yields 2Mi rows. An explicit SMOLQUERY_COMPACT_MAX_ROWS still wins, a tiny budget floors at 64Ki rows, and no resolvable budget keeps the old 4Mi default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFEnyub9GiAqwDWRRtMKBs * The row cap adapts per table: halve on merge OOM, recover on success (T-262) A budget-derived cap fixes the budget dimension and not the workload one. The 512 B/row constant is calibrated on this bench's data; wide repetitive text fields pin kilobytes per row while compressing well enough to pass both static caps. No footer statistic predicts pin cost across workloads, so the cap answers the workload instead: a merge OOM halves the table's cap (floor 64Ki rows), a successful compaction doubles it back, and the override is shed at the resolved cap. Caps live in the compactor's state; a restart re-learns them on the next OOM. The sweeper contract grows an optional three-tuple so a sweep can carry state; retention's two-tuple form is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFEnyub9GiAqwDWRRtMKBs * Hoist split_result out of the Sweeper quote for dialyzer Injected into each using module, the helper's unused clauses are dead code per expansion — the compactor never returns a two-tuple error, the retention sweeper never returns three-tuples — and dialyzer rightly flags patterns that can never match. As one shared function the clauses are all reachable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFEnyub9GiAqwDWRRtMKBs * Alias the Sweeper in its own quote for credo Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GFEnyub9GiAqwDWRRtMKBs --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The write path's 429s were correct and too late: buffer_full and
{:overloaded, _} fire after the request body is in RAM, so nothing
bounded resident bodies and memory grew with client concurrency until
the kernel OOMKilled the pod — three kills in one in-region sweep at
up to 128 VUs x 6.87 MiB.
SmolqueryApi.Admission counts POST insert/load bodies against an
in-flight byte limit between auth and parsing, while the request is
still a header. The reservation is the declared content-length capped
at the route's own body limit; no declared length reserves the limit
outright. Over the limit answers 429 with retry-after; an idle counter
always admits one request, so a limit below one body cannot brick
ingest. The counter releases when the response sends, and a monitor
releases on request crash.
The limit derives as a quarter of the cgroup memory limit, floored at
one NDJSON body; SMOLQUERY_INSERT_MAX_IN_FLIGHT_BYTES overrides it.
Claude-Session: https://claude.ai/code/session_01GFEnyub9GiAqwDWRRtMKBs
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Review fixes for the admission stack (T-264, T-265, T-266) - Compactor: a staging-phase OOM now tightens the row cap; only the store-wrapped shape matched before, so the same group re-OOMed every sweep. - Compactor: a cap raise now needs evidence — a cap-filling success or a cap-forced skip — and `patience` such sweeps; each OOM doubles the patience, so a table at its true limit probes it rarely instead of every second sweep, and a cap wedged at the floor unwedges. - Compactor: a timed-out final COPY (store-wrapped CallExited) now recycles the wedged compaction engine. - Compactor: `compact_max_rows` resolves once at start, not on every sweep. - Storage runtime: the row cap derives from the cgroup bytes directly, with no string round-trip; fractional budgets like "1.5GB" parse; an unreadable explicit budget logs a warning instead of a silent fallback. - Admission: a stray message no longer crashes the server (and, under rest_for_one, the Endpoint with it). - Admission: a server that dies between the lookup and the call passes the request uncounted instead of a 500. - API runtime: an explicit `insert_max_in_flight_bytes` is validated at boot; the derivation floor reuses `InsertController.max_ndjson_bytes/0`. - Whole-request load reservations stay by design; the moduledoc now states the tradeoff. - Docs: the `SMOLQUERY_COMPACT_MAX_ROWS` derivation, the new `SMOLQUERY_INSERT_MAX_IN_FLIGHT_BYTES` row, and the admission 429 in api.md. Refs T-264, T-265, T-266. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * The row cap starts at the default, not at a bench-derived pin rate (T-266) - The 512 B/row figure came from a bench with very large rows. A cap derived from it starves typical workloads. - An unset `compact_max_rows` now starts at `4194304` rows. The per-table adaptation — halve on OOM, earn it back with evidence — fits the cap to each workload instead. - This removes the budget-string parser and the per-sweep cgroup reads with it; `with_compact_max_rows/1` no longer takes a cgroup argument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * One helper owns the 429 shed-load contract (T-265) - `Errors.send_resource_exhausted/3` pairs the 429 envelope with its retry-after. - The four hand-built copies — admission, buffer full, overload, job ceiling — now call it, so the contract cannot drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
djwhitt
marked this pull request as draft
August 16, 2026 17:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduce the provider-neutral authentication and authorization contracts that the OIDC stack will build on:
Smolquery.Auth.Principal— a structurally validated stable identity with no provider claim bag or token materialSmolquery.Auth.Context— one principal, an explicit current scope, a closed capability set, and assertion expiry that is required for OIDC and optional for static credentialsSmolquery.Auth.Policy— fail-closed, expiry-aware authorization that preserves the401/403distinction and identifies unknown internal capabilitiesSmolquery.Auth— one attachment seam for Plug connections and LiveView socketsThis is deliberately a behavior-neutral foundation. The existing static API-key and web Basic-auth flows are not rewired in this PR. OIDC discovery, JWT verification, browser redirects, role mapping, and route enforcement are also outside this PR.
Closes T-229. Refs PL-27.
Why this is a separate layer
Without an internal contract, adding OIDC directly to
SmolqueryApi.AuthorSmolqueryWeb.Authwould make provider-specific claims the application's authorization model. Controllers and LiveViews would eventually learn aboutgroups,roles,scope, email addresses, or one provider's token shape.This layer creates the boundary first:
flowchart LR subgraph Authenticators[Authentication adapters — outside this PR] APIKey[Static API key] Basic[Web Basic auth] OIDC[OIDC / OAuth tokens] end APIKey -.-> Principal Basic -.-> Principal OIDC -.-> Principal Principal[Auth.Principal<br/>who authenticated] Principal --> Context[Auth.Context<br/>principal + scope + capabilities + expiry] Context --> Attach{Request carrier} Attach --> Conn[Plug.Conn assigns] Attach --> Socket[LiveView.Socket assigns] Conn --> Policy[Auth.Policy] Socket --> Policy Policy --> App[Controllers / LiveViews]Authentication adapters are responsible for interpreting provider-specific inputs. Consumers of this contract receive a
Principaland askPolicyabout a normalized capability.Design
Principal: identity without provider leakage
Smolquery.Auth.Principalcarries:idauthn:oidc,:api_key, or:basickind:useror:serviceissuer/subjectdisplay_nameclient_idThere is intentionally no field for raw tokens, refresh tokens, provider claims, email, username, groups, or roles.
OIDC identity
The canonical external identity is the exact pair
{issuer, subject}. Neither value is trimmed, case-folded, or otherwise normalized.Principal.oidc/4derives an opaque, stable pseudonymous identifier using a versioned encoding:Length framing prevents delimiter ambiguity, so
{a\0b, c}cannot collide at the encoding layer with{a, b\0c}. Lengths are unsigned big-endian 32-bit byte counts; the full SHA-256 digest uses unpadded base64url. Thev1prefix makes the durable derivation explicit. A fixed test vector prevents accidental changes to this encoding.Principal.well_formed?/1recomputes the OIDC ID fromissuerandsubject; a struct with a substituted ID is structurally rejected. This check does not prove authentication provenance. Only trusted authentication adapters may construct principals, and client input is never decoded directly into these structs.Smolquery treats differing exact
{issuer, subject}pairs as distinct identities, including pairwise subjects issued to different clients or sectors. It never links identities through mutable display claims such as email or username. The derived ID relies on SHA-256 collision resistance and is pseudonymous audit data, not anonymous or categorically non-PII.Local identities
Principal.local/4represents credentials managed by smolquery. It accepts only:api_keyor:basic; callers cannot create a caller-selected:oidcidentity. The caller supplies a stable non-secret source key such asstatic:api, never the credential itself. The constructor does not retain that source key; it derives the final ID in an authentication-specific namespace:The disjoint constructor-owned namespaces prevent local identities from colliding with OIDC or with one another. Local ID validation also requires one canonical unpadded base64url encoding.
Both constructors:
display_nameandclient_idoptions;Context: authorization is scoped
Smolquery.Auth.Contextcombines:The capability vocabulary is closed:
:web_access:query:ingest:catalog_manage:platform_operate:platform_operateis deliberately separate from tenant-administrator permissions.Capabilities are stored in a
MapSet, so duplicates collapse. Unknown atoms and strings fail construction rather than becoming dynamic atoms or implicit grants.granted?/2also fails closed for unknown capabilities and malformed contexts.expires_atis a Unix epoch time in integer seconds and belongs to the request or session context rather than the stable principal identity. It is required for OIDC contexts and optional for local static contexts, which may usenil.Context.active?/2uses the strict boundarynow < expires_at; the context is expired at equality. OIDC adapters must validate token timestamps and apply their bounded clock-skew policy before constructing the context. Policy rechecks the absolute expiry on every authorization decision so a long-lived LiveView socket cannot retain capability access indefinitely.The single-tenant sentinel
The current scope is explicitly
:single_tenant. It is not derived from:This makes the scope explicit while leaving a replacement seam for a membership lookup:
The PR does not make catalog keys, storage paths, query engines, or datasets tenant-isolated.
Policy: preserve authentication versus authorization
Smolquery.Auth.Policy.authorize/2has one fail-closed decision path:flowchart TD Start[authorize context, capability] --> Active{Well-formed active context?} Active -- No --> Unauthenticated[error: unauthenticated<br/>HTTP adapter maps to 401] Active -- Yes --> Known{Known internal capability?} Known -- No --> Invalid[error: invalid_capability<br/>programmer/configuration defect] Known -- Yes --> Granted{Capability granted?} Granted -- Yes --> Allowed[ok] Granted -- No --> Forbidden[error: forbidden<br/>HTTP adapter maps to 403]The domain layer returns tagged decisions rather than HTTP responses. At HTTP integration boundaries, these decisions map as follows:
:unauthenticatedto401;:forbiddento403;:invalid_capabilityto an internal programming/configuration failure, never an end-user denial.A missing, malformed, or expired context is not treated as an authenticated principal with insufficient permissions; it remains unauthenticated.
authorize/2reads Unix time from the system clock, whileauthorize/3accepts an explicit timestamp for deterministic tests.One Plug and LiveView attachment seam
Smolquery.Authstores a well-formed context under the stable assign key::smolquery_auth_contextThe same API works for both request carriers:
This avoids putting identity into
conn.private, which already carries API runtime-instance state, and provides the same normalized attachment contract for Plug connections and LiveView sockets. Route and mount enforcement remain outside this PR.Assignment rejects malformed contexts. Fetching a missing or malformed assignment returns
:error. These attachment checks prove structure only; they do not authenticate a caller or authorize a capability. Trusted adapters establish authentication provenance before assignment, and every access decision goes throughPolicy, which checks expiry and capabilities. Regression coverage includes partial struct-tagged principal maps and malformedMapSetinternals so structural checks fail closed without raising.Security properties
{issuer, subject}is the OIDC identity; display metadata cannot change or merge it.String.to_atom/1.What this PR does not do
Those concerns are outside this PR so the provider-neutral contract remains independently reviewable.
Validation
Checks (mix ci), the fast test suite, and Dialyzer passThe tests cover:
MapSetinternals failing closed without raising;401,403, and invalid-internal-capability policy decisions;Stack created with GitHub Stacks CLI • Give Feedback 💬