11 KiB
Spec: Scoped Access (Agents & Entries)
Overview
Implement scoped access control for the vault. Every agent/human gets a token with one or more scopes. Every entry has zero or more scopes. Access is granted by set intersection. Admin operations (create/modify/delete agents and entries) require WebAuthn signature.
Schema changes
agents table
CREATE TABLE agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT UNIQUE NOT NULL, -- sha256 of bearer token
name TEXT NOT NULL, -- human-readable: "Claude Code", "Sarah", "Deploy CI"
scopes TEXT NOT NULL DEFAULT '', -- comma-separated 4-char hex: "" or "0001" or "0001,0003"
all_access INTEGER NOT NULL DEFAULT 0, -- 1 = bypass scope check, read all entries
admin INTEGER NOT NULL DEFAULT 0, -- 1 = can execute admin ops (with WebAuthn)
created_at INTEGER NOT NULL -- unix timestamp
);
CREATE INDEX idx_agents_token ON agents(token_hash);
Notes:
idis auto-increment and IS the scope identifier. Display as 4-char zero-padded hex:printf('%04x', id).scopesfield on the agent row contains the agent's own scope(s). For most agents this is just their own id hex. For MSP technicians with multiple roles, it can be multiple:"000a,000b".all_access = 1means this agent can read all entries regardless of scope. Does NOT grant admin rights.admin = 1means this agent can perform admin operations. Still requires WebAuthn signature per request.all_access = 1, admin = 1= vault owner.all_access = 1, admin = 0= MSP technician with full read access, no admin.all_access = 0, admin = 0= scoped read-only. Standard agent or family member.- Token is generated server-side, shown once to the user, stored as sha256 hash. The raw token is never stored.
entries table (modification)
Add scopes column to existing entries table:
ALTER TABLE entries ADD COLUMN scopes TEXT NOT NULL DEFAULT '';
Notes:
- Empty
""= owner-only. No agent or family member can see this. Onlyall_access = 1tokens. - Single scope
"0002"= only agents whose scopes include0002. - Multiple scopes
"0002,0004,0005"= any agent whose scopes include any of these (OR logic). - Comma-separated 4-char zero-padded hex. No spaces.
- Maximum realistic size: 10 scopes = 49 bytes. Negligible.
Token format
Bearer token format: cvt_ prefix + 32 bytes random (base62 encoded).
Example: cvt_7kQ9mR2xP4wL8nB3vF6jH1cT5yA0dE4s
Stored as: sha256("cvt_7kQ9mR2xP4wL8nB3vF6jH1cT5yA0dE4s")
The cvt_ prefix allows secret scanning tools (GitHub, GitLab) to detect leaked tokens.
Scope matching logic
Function: scopeMatch(agentScopes string, entryScopes string) bool
If entryScopes is empty → return false (owner-only entry)
Split agentScopes by ","
Split entryScopes by ","
Return true if any element in agentScopes exists in entryScopes
SQL implementation
For a single agent scope (most common case):
SELECT * FROM entries
WHERE id = :entry_id
AND (
:all_access = 1
OR (',' || scopes || ',' LIKE '%,' || :agent_scope || ',%')
)
AND scopes != '' -- redundant if LIKE already fails, but explicit
For multi-scope agents (MSP technicians):
The agent's scopes field may contain multiple values. Application code should split the agent's scopes and check each, OR build a dynamic OR clause:
-- For agent with scopes "000a,000b":
SELECT * FROM entries
WHERE id = :entry_id
AND (
:all_access = 1
OR (',' || scopes || ',' LIKE '%,000a,%')
OR (',' || scopes || ',' LIKE '%,000b,%')
)
For listing all accessible entries:
-- Single-scope agent:
SELECT * FROM entries
WHERE :all_access = 1
OR (',' || scopes || ',' LIKE '%,' || :agent_scope || ',%')
-- all_access agent (owner, full-access MSP tech):
SELECT * FROM entries
API endpoints
Read operations (bearer token only)
GET /api/entries
Auth: Bearer token
Returns: all entries matching agent's scope (id, name, scopes, L2 fields, L3 ciphertext)
If all_access: returns all entries
GET /api/entries/:id
Auth: Bearer token
Returns: single entry if scope matches
403 if scope mismatch
GET /api/ext/totp/:id
Auth: Bearer token
Returns: { "code": "284919", "expires_in": 14 }
403 if scope mismatch
GET /api/search?q=:query
Auth: Bearer token
Returns: entries matching query AND scope
Full-text search on name, url, username, notes within scoped entries
Admin operations (bearer token + WebAuthn)
All admin requests must include:
Authorization: Bearer <token>headerX-WebAuthn-Assertion: <base64-encoded signed assertion>headerX-WebAuthn-Challenge: <challenge-id>header
Server validates: token exists AND admin=1 AND WebAuthn signature valid AND challenge valid and not expired.
POST /api/webauthn/challenge
Auth: Bearer token (admin=1 required)
Returns: { "challenge": "<random-base64>", "challenge_id": "<uuid>", "ttl": 60 }
Stores challenge server-side with 60s TTL.
POST /api/agents
Auth: Bearer + WebAuthn
Body: { "name": "Claude Code", "scopes": "auto", "all_access": false, "admin": false }
If scopes is "auto": assigns next ordinal as scope (printf('%04x', new_id))
Returns: { "id": 2, "scope": "0002", "token": "cvt_...", "name": "Claude Code" }
Token shown ONCE in response. Never retrievable again.
GET /api/agents
Auth: Bearer (admin=1 required, no WebAuthn needed for read)
Returns: list of all agents (id, name, scopes, all_access, admin, created_at). Never returns token_hash.
PUT /api/agents/:id
Auth: Bearer + WebAuthn
Body: { "name": "New name", "scopes": "0002,0005", "all_access": false, "admin": false }
Updates agent row. Cannot modify own admin flag (prevent self-lockout? or allow?).
DELETE /api/agents/:id
Auth: Bearer + WebAuthn
Deletes agent row. Token immediately invalid.
Cannot delete self (prevent lockout).
POST /api/entries
Auth: Bearer + WebAuthn
Body: { "name": "GitHub", "scopes": "0002,0003", "fields": { ... } }
Creates entry with specified scopes.
PUT /api/entries/:id
Auth: Bearer + WebAuthn
Body: { "name": "GitHub", "scopes": "0002,0003,0005", "fields": { ... } }
Updates entry including scope list.
PUT /api/entries/:id/scopes
Auth: Bearer + WebAuthn
Body: { "scopes": "0002,0003,0005" }
Updates only the scopes column. Convenience endpoint for managing access without touching fields.
DELETE /api/entries/:id
Auth: Bearer + WebAuthn
Deletes entry.
WebAuthn implementation
Storage
CREATE TABLE webauthn_credentials (
id TEXT PRIMARY KEY, -- credential ID from WebAuthn registration
public_key BLOB NOT NULL, -- public key bytes
sign_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE webauthn_challenges (
challenge_id TEXT PRIMARY KEY,
challenge TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL -- created_at + 60
);
Challenge lifecycle
POST /api/webauthn/challenge→ generate 32 bytes random, store with 60s TTL, return challenge + challenge_id.- Client receives challenge, triggers WebAuthn
navigator.credentials.get()with the challenge. - User taps hardware key.
- Client sends admin request with signed assertion + challenge_id.
- Server: look up challenge by challenge_id. Verify not expired. Verify signature against stored public key. Verify sign_count is incrementing. Delete challenge (single use).
- If all valid → execute admin operation.
Cleanup
Expired challenges should be deleted periodically. Run on each challenge request or via a simple sweep:
DELETE FROM webauthn_challenges WHERE expires_at < unixepoch();
Authentication flow
Request processing (pseudocode)
func handleRequest(r *http.Request) {
// 1. Extract bearer token
token := extractBearer(r)
if token == "" {
return 401
}
// 2. Look up agent
agent := db.QueryRow("SELECT id, scopes, all_access, admin FROM agents WHERE token_hash = ?", sha256(token))
if agent == nil {
return 401
}
// 3. If admin operation, verify WebAuthn
if isAdminEndpoint(r) {
if agent.admin != 1 {
return 403
}
if !verifyWebAuthn(r) {
return 403
}
}
// 4. For read operations, check scope
if isReadEndpoint(r) {
entryID := extractEntryID(r)
entry := getEntry(entryID)
if !agent.all_access && !scopeMatch(agent.scopes, entry.scopes) {
return 403
}
// Serve entry (L2 decrypted, L3 as ciphertext)
}
}
Scope display
In the web UI and API responses, scopes are displayed as both hex ID and name:
{
"id": 2,
"scope": "0002",
"name": "Claude Code",
"scopes": "0002",
"all_access": false,
"admin": false
}
Entry scope display resolves IDs to names:
{
"id": 1,
"name": "GitHub token",
"scopes": "0002,0003",
"scope_names": ["Claude Code", "Deploy CI"],
"fields": { ... }
}
Migration
For existing vaults (no scopes yet)
- Add
scopescolumn to entries:ALTER TABLE entries ADD COLUMN scopes TEXT NOT NULL DEFAULT ''; - Create agents table.
- Existing auth tokens become agent rows: create agent row for each existing token with
all_access=1, admin=1(vault owner). - All existing entries have
scopes = ""(owner-only) by default. This is the secure default — nothing changes for existing users until they explicitly create agents and assign scopes.
Vault owner bootstrap
On first vault setup or migration:
- User registers WebAuthn credential (hardware key enrollment).
- System creates first agent row:
id=1, name="Owner", all_access=1, admin=1. - User receives their bearer token (shown once).
- All entries default to
scopes=""(owner only). - User creates additional agents and assigns scopes via admin operations.
Constraints & validation
- Agent name: 1-100 characters, non-empty.
- Scopes string: must match pattern
^([0-9a-f]{4})(,[0-9a-f]{4})*$or be empty string. - Token: 32 bytes random, base62 encoded,
cvt_prefix. Generated server-side only. - Cannot delete the last admin agent (prevent lockout).
- Cannot remove admin flag from self if no other admin exists.
- Maximum agents per vault: enforced by pricing tier (5, 15, 50, unlimited).
- Maximum devices (WebAuthn credentials): enforced by pricing tier (2, 6, unlimited).
Tier enforcement
The vault itself does not enforce tier limits. The hosted management layer (outside the vault) enforces:
- Token count:
SELECT COUNT(*) FROM agentschecked beforePOST /api/agents. - Device count:
SELECT COUNT(*) FROM webauthn_credentialschecked before WebAuthn registration. - Vault count: checked at the account level, not within the vault.
Self-hosted vaults have no limits.