Skip to content

feat(auth): real AuthProvider — persisted per-workspace tokens, one door for issuance (#25) - #97

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat/25-auth-provider
Jul 28, 2026
Merged

feat(auth): real AuthProvider — persisted per-workspace tokens, one door for issuance (#25)#97
JArmandoAnaya merged 1 commit into
mainfrom
feat/25-auth-provider

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #25. Third task of M3 (3/14), branched from 4217ce1.

Replaces EnvTokenAuthProvider — an adapter living inside server/main.py, reached through a module global — with the real thing.

The shape

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.

Piece Where
Token, IssuedToken, generate_secret, hash_secret kernel/domain/token.py
TokenService — create / get / get_by_name / revoke / list kernel/services/token_service.py
StoredTokenAuthProvider kernel/adapters/ — the sixth port on WorkspaceService
get_workspace / get_auth_provider / require_token / protected_router() server/dependencies.py

Decisions worth reviewing

  • SHA-256, not a KDF. A KDF makes low-entropy human-chosen input expensive to guess; the input is 256 bits of 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 in docs/auth.md; the unsalted consequence is stated rather than hidden.
  • revoked_at, not a bool. revoked is derived, the doctrine that keeps a schema's active version computed. A bool would make "when?" need migration 12.
  • Nothing is cached. "Revoked ⇒ refused" has to mean now, and a revoked token failing a request is an acceptance criterion. A store failure raises rather than answering False — an outage is not a bad credential.
  • One identical 401 for missing / malformed / unknown / revoked, so the response cannot be used to probe which credentials exist.
  • Names unique per workspace (uq_token_workspace_name, COLLATE NOCASE) so cli: visionset token create/list/revoke with persistence #26's token revoke <name> resolves to one credential — and so cli: visionset token create/list/revoke with persistence #26 needs no second M3 migration.
  • Zero MCP tools for token administration, deliberately. Recorded in docs/auth.md so 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_VERSION 10 → 11

First 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 token line in _downgrade_to_version_one, the fresh-versus-migrated test would still pass. The table would survive the downgrade and the migration would checkfirst-skip, so the CREATE nobody ran would be reported as agreeing with itself. That undo is the only thing exercising migration 11 at all.

openapi.json does not move

The 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 HTTPBearer emits nothing while zero routes are protected. components.securitySchemes enters with #27's first endpoint. The drift gate is green with no diff, and test_the_committed_contract_has_no_security_scheme_yet pins that until it changes.

The scheme's description is set now anyway, and test_the_bearer_scheme_enters_the_spec_with_this_exact_shape pins the diff #27 will commit — a contract decision #27 should not have to make.

Two deliberate consequences

  • A misconfigured server answers 500 NOT_A_WORKSPACE, not 401, even with no token. get_auth_provider depends on get_workspace, and FastAPI resolves sub-dependencies before the body. Short-circuiting would mean resolving the provider outside the dependency graph, which takes dependency_overrides with it — and making that work is half of what this issue is for. Covered explicitly.
  • visionset token create now exits non-zero instead of printing a plausible vst_… that could never authenticate. cli: visionset token create/list/revoke with persistence #26 wires it, along with workspace resolution; resolve_workspace_root here is provisional and says so.

Also

  • Two new kernel errors — 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.py hold shared helpers as plain functions; still no conftest.py anywhere in the repo. dependency_overrides goes from 0 uses to the default mechanism, retiring the bare-FastAPI() probe pattern.
  • Fixed a latent landmine in test_every_route_documents_the_universal_error_responses: it iterated every key under a path item and would have raised KeyError on a legal non-operation key. Both walks now share one definition of "an operation".
  • New docs/auth.md; docs/api.md, persistence.md, workspaces.md, README.md and docker/compose.yaml updated.

Checks

ruff check . / ruff format --check .   clean
mypy src/visionset/kernel               54 files, clean
mypy src/visionset                      66 files, clean
lint-imports                            2 contracts kept
pytest                                  1016 passed (was 919)
examples/sdk_end_to_end.py              ok
examples/ingest_end_to_end.py           ok
export_openapi.py + git diff            no drift

VERSION unchanged at 0.0.1.dev0. No new dependency, no import-linter change, no new domain event.

…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.
@JArmandoAnaya
JArmandoAnaya merged commit e2277f9 into main Jul 28, 2026
3 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/25-auth-provider branch July 28, 2026 00:33
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: real AuthProvider — persisted per-workspace tokens, create/revoke, bearer auth across the whole API

1 participant