feat(auth): real AuthProvider — persisted per-workspace tokens, one door for issuance (#25) - #97
Merged
Merged
Conversation
…oor for issuance (#25) Replaces `EnvTokenAuthProvider` — an adapter living inside `server/main.py`, reached through a module global — with the real thing: a persisted `Token` entity, issuance as a kernel service, a provider in `kernel/adapters/`, and a FastAPI dependency that every future endpoint inherits. The port does not widen. `AuthProvider.verify(token) -> bool` stays the narrow seam all three surfaces authenticate through; minting and revoking are use cases, so they are `TokenService`. Kernel - `domain/token.py`: `Token` (name, digest, created_at, revoked_at) and `IssuedToken`, the only object that ever holds a plaintext — `repr=False`, so "shown exactly once" is a shape rather than a docstring. `revoked` is derived from `revoked_at`, the doctrine that keeps a schema's active version computed. - Secrets are 256 bits of `secrets.token_urlsafe`, stored as SHA-256. A KDF makes low-entropy input expensive to guess; there is nothing to guess here, and verification runs per request against N tokens. Argued in `docs/auth.md`. - `TokenService`: create / get / get_by_name / revoke(confirm=) / list. No delete and no rename — the row is the audit record. Re-revoking is a no-op that keeps the first timestamp. No domain event: an auth trail a subscriber can silently drop is worse than none, and the columns are durable. - `StoredTokenAuthProvider` takes the `MetadataStore` port and a workspace id, never a service. Nothing is cached, because "revoked ⇒ refused" must mean now. A store failure raises rather than answering False — an outage is not a bad credential. - `AuthProvider` becomes the sixth port on `WorkspaceService`, appended last. Its factory is the first to take arguments, being derived from another port. Migration 11 — M3's only one; FORMAT_VERSION 10 → 11 - New `token` table, so idempotency is `Table.create(checkfirst=True)`, which brings both indexes with it and cannot meet migration 8's expression-index trap. `uq_token_workspace_name` (COLLATE NOCASE) makes `token revoke <name>` resolve to one credential. - Without the `drop table token` line in `_downgrade_to_version_one` the fresh-versus-migrated test would still pass: the table would survive and the migration would checkfirst-skip. That undo is the only thing exercising it. Server - New `server/dependencies.py` — a module of its own to avoid the main → routes → main cycle #27 would otherwise hit. `get_workspace` / `get_auth_provider` / `require_token`, all sync, plus `protected_router()` carrying the guard and its documented 401 together. - The workspace is opened lazily per application and closed on shutdown, never at import time: `scripts/export_openapi.py` imports the module-level `app` in a checkout with no workspace. - `VISIONSET_WORKSPACE` resolution is provisional and marked as such — #26 owns the documented precedence and replaces the function body. - Missing, malformed, unknown and revoked are one identical 401, so the response cannot be used to probe which credentials exist. - `openapi.json` does not move: FastAPI collects security schemes per route, and nothing is protected yet. It moves in #27, with the first real endpoint. Two new kernel errors, `TokenNotFound` (404) and `TokenNameTaken` (409), mapped in `ERROR_RULES` — #31's exhaustiveness tripwire fired for the first time, as intended. `visionset token create` stops printing a credential that could never authenticate; #26 wires it. No MCP tool for token administration, argued in `docs/auth.md`. 1016 tests, up from 919. VERSION unchanged, no new dependency, no import-linter change, no new domain event.
3 tasks
JArmandoAnaya
added a commit
that referenced
this pull request
Aug 21, 2026
…oor for issuance (#25) (#97) Replaces `EnvTokenAuthProvider` — an adapter living inside `server/main.py`, reached through a module global — with the real thing: a persisted `Token` entity, issuance as a kernel service, a provider in `kernel/adapters/`, and a FastAPI dependency that every future endpoint inherits. The port does not widen. `AuthProvider.verify(token) -> bool` stays the narrow seam all three surfaces authenticate through; minting and revoking are use cases, so they are `TokenService`. Kernel - `domain/token.py`: `Token` (name, digest, created_at, revoked_at) and `IssuedToken`, the only object that ever holds a plaintext — `repr=False`, so "shown exactly once" is a shape rather than a docstring. `revoked` is derived from `revoked_at`, the doctrine that keeps a schema's active version computed. - Secrets are 256 bits of `secrets.token_urlsafe`, stored as SHA-256. A KDF makes low-entropy input expensive to guess; there is nothing to guess here, and verification runs per request against N tokens. Argued in `docs/auth.md`. - `TokenService`: create / get / get_by_name / revoke(confirm=) / list. No delete and no rename — the row is the audit record. Re-revoking is a no-op that keeps the first timestamp. No domain event: an auth trail a subscriber can silently drop is worse than none, and the columns are durable. - `StoredTokenAuthProvider` takes the `MetadataStore` port and a workspace id, never a service. Nothing is cached, because "revoked ⇒ refused" must mean now. A store failure raises rather than answering False — an outage is not a bad credential. - `AuthProvider` becomes the sixth port on `WorkspaceService`, appended last. Its factory is the first to take arguments, being derived from another port. Migration 11 — M3's only one; FORMAT_VERSION 10 → 11 - New `token` table, so idempotency is `Table.create(checkfirst=True)`, which brings both indexes with it and cannot meet migration 8's expression-index trap. `uq_token_workspace_name` (COLLATE NOCASE) makes `token revoke <name>` resolve to one credential. - Without the `drop table token` line in `_downgrade_to_version_one` the fresh-versus-migrated test would still pass: the table would survive and the migration would checkfirst-skip. That undo is the only thing exercising it. Server - New `server/dependencies.py` — a module of its own to avoid the main → routes → main cycle #27 would otherwise hit. `get_workspace` / `get_auth_provider` / `require_token`, all sync, plus `protected_router()` carrying the guard and its documented 401 together. - The workspace is opened lazily per application and closed on shutdown, never at import time: `scripts/export_openapi.py` imports the module-level `app` in a checkout with no workspace. - `VISIONSET_WORKSPACE` resolution is provisional and marked as such — #26 owns the documented precedence and replaces the function body. - Missing, malformed, unknown and revoked are one identical 401, so the response cannot be used to probe which credentials exist. - `openapi.json` does not move: FastAPI collects security schemes per route, and nothing is protected yet. It moves in #27, with the first real endpoint. Two new kernel errors, `TokenNotFound` (404) and `TokenNameTaken` (409), mapped in `ERROR_RULES` — #31's exhaustiveness tripwire fired for the first time, as intended. `visionset token create` stops printing a credential that could never authenticate; #26 wires it. No MCP tool for token administration, argued in `docs/auth.md`. 1016 tests, up from 919. VERSION unchanged, no new dependency, no import-linter change, no new domain event.
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.
Closes #25. Third task of M3 (3/14), branched from
4217ce1.Replaces
EnvTokenAuthProvider— an adapter living insideserver/main.py, reached through a module global — with the real thing.The shape
The port does not widen.
AuthProvider.verify(token) -> boolstays the narrow seam all three surfaces authenticate through; minting and revoking are use cases, so they areTokenService.Token,IssuedToken,generate_secret,hash_secretkernel/domain/token.pyTokenService— create / get / get_by_name / revoke / listkernel/services/token_service.pyStoredTokenAuthProviderkernel/adapters/— the sixth port onWorkspaceServiceget_workspace/get_auth_provider/require_token/protected_router()server/dependencies.pyDecisions worth reviewing
secrets.token_urlsafe. Verification also runs on every request against N tokens, so a 100 ms KDF would cost N × 100 ms per request. Argued indocs/auth.md; the unsalted consequence is stated rather than hidden.revoked_at, not a bool.revokedis derived, the doctrine that keeps a schema's active version computed. A bool would make "when?" need migration 12.False— an outage is not a bad credential.uq_token_workspace_name,COLLATE NOCASE) so cli: visionset token create/list/revoke with persistence #26'stoken revoke <name>resolves to one credential — and so cli: visionset token create/list/revoke with persistence #26 needs no second M3 migration.docs/auth.mdso mcp: real tools over the SDK — management, schema, ingest, jobs, annotation, releases (the Part III §4 list), confirm:true on destructive ops #35 does not re-open it: minting a credential is a privilege-escalation primitive pointed at the agent's own sandbox, and an agent's "shown once" is a transcript.Migration 11 — M3's only one,
FORMAT_VERSION10 → 11First migration since 1 to create a table, so idempotency is
Table.create(checkfirst=True)— which brings both indexes with it and cannot meet migration 8's expression-index trap.The trap worth knowing: without the
drop table tokenline in_downgrade_to_version_one, the fresh-versus-migrated test would still pass. The table would survive the downgrade and the migration wouldcheckfirst-skip, so theCREATEnobody ran would be reported as agreeing with itself. That undo is the only thing exercising migration 11 at all.openapi.jsondoes not moveThe epic predicted #25 would be the first task to move it. It is not: FastAPI collects security definitions per route, from the route's dependency tree, so a module-level
HTTPBeareremits nothing while zero routes are protected.components.securitySchemesenters with #27's first endpoint. The drift gate is green with no diff, andtest_the_committed_contract_has_no_security_scheme_yetpins that until it changes.The scheme's
descriptionis set now anyway, andtest_the_bearer_scheme_enters_the_spec_with_this_exact_shapepins the diff #27 will commit — a contract decision #27 should not have to make.Two deliberate consequences
NOT_A_WORKSPACE, not 401, even with no token.get_auth_providerdepends onget_workspace, and FastAPI resolves sub-dependencies before the body. Short-circuiting would mean resolving the provider outside the dependency graph, which takesdependency_overrideswith it — and making that work is half of what this issue is for. Covered explicitly.visionset token createnow exits non-zero instead of printing a plausiblevst_…that could never authenticate. cli: visionset token create/list/revoke with persistence #26 wires it, along with workspace resolution;resolve_workspace_roothere is provisional and says so.Also
TokenNotFound(404),TokenNameTaken(409). server: uniform error handling — domain errors → HTTP with stable codes and detail (part of the contract) #31's exhaustiveness tripwire fired for the first time, as designed.tests/server/_probe.py+_openapi.pyhold shared helpers as plain functions; still noconftest.pyanywhere in the repo.dependency_overridesgoes from 0 uses to the default mechanism, retiring the bare-FastAPI()probe pattern.test_every_route_documents_the_universal_error_responses: it iterated every key under a path item and would have raisedKeyErroron a legal non-operation key. Both walks now share one definition of "an operation".docs/auth.md;docs/api.md,persistence.md,workspaces.md,README.mdanddocker/compose.yamlupdated.Checks
VERSIONunchanged at0.0.1.dev0. No new dependency, no import-linter change, no new domain event.