Auth Service Implementation Plan - TODAY
Revised Architecture Based on Feedback
Key Decisions
- No New Table - Extend existing
auth.secretswith graph node ownership - No Migrations - Modify existing SQL files directly (app not deployed)
- Graph Integration - Secrets owned by graph nodes, use existing RLS patterns
- System Secrets - Root KEK/MEK remain system-owned (no node_id)
- Cursor Only - Remove
list(), only usecursor()with sensible defaults - Better Taxonomy - Use ltree for secret categories, similar to node types
Database Schema Changes (db/203_auth_secrets.sql)
Add to existing auth.secrets table:
-- Add graph node ownership (nullable for system secrets)
ALTER TABLE auth.secrets ADD COLUMN node_id UUID
REFERENCES graph.nodes(id) ON DELETE CASCADE;
-- Index for node-scoped queries
CREATE INDEX IF NOT EXISTS secrets_node_id_idx
ON auth.secrets(node_id) WHERE node_id IS NOT NULL;
-- Update RLS policies to use graph access patterns
Replace auth.key_scope enum with ltree taxonomy:
-- Instead of enum, use ltree for flexible hierarchy
-- secret_category examples:
-- 'system.mek' - Root master encryption key
-- 'system.kek' - Key encryption key
-- 'app.jwt.signing' - Application JWT signing key
-- 'app.encryption' - Application encryption key
-- 'user.api_key' - Per-user API key
-- 'user.oauth.github' - User OAuth token for GitHub
-- 'user.oauth.google' - User OAuth token for Google
-- 'org.api_key' - Per-org API key
-- 'org.integration.stripe' - Org integration key for Stripe
ALTER TABLE auth.secrets ADD COLUMN secret_category LTREE;
-- Migrate existing key_scope to secret_category
UPDATE auth.secrets SET secret_category =
CASE key_scope
WHEN 'system' THEN 'system'::ltree
WHEN 'root' THEN 'system'::ltree
WHEN 'application' THEN 'app'::ltree
WHEN 'user' THEN 'user'::ltree
END;
-- Can now drop old enum after migration
ALTER TABLE auth.secrets DROP COLUMN key_scope;
New RLS Policies (replace existing):
-- Drop old policies
DROP POLICY IF EXISTS secrets_no_direct_mek_access ON auth.secrets;
-- System secrets (no node_id) - only admins and crypto internals
CREATE POLICY secrets_system_access ON auth.secrets
FOR ALL
USING (
node_id IS NULL AND (
pg_has_role(CURRENT_USER, 'auth_secrets_admin', 'MEMBER') OR
current_setting('application_name') = 'internal-crypto'
)
);
-- Node-scoped secrets - use graph access patterns
CREATE POLICY secrets_node_readable ON auth.secrets
FOR SELECT
USING (
node_id IS NOT NULL AND
graph.node_is_readable(node_id)
);
CREATE POLICY secrets_node_writable ON auth.secrets
FOR INSERT, UPDATE
WITH CHECK (
node_id IS NOT NULL AND
graph.node_is_writeable(node_id)
);
CREATE POLICY secrets_node_deletable ON auth.secrets
FOR DELETE
USING (
node_id IS NOT NULL AND
graph.node_is_managed(node_id)
);
Register cursor type:
SELECT cursors.register_cursor_type(
'auth.secrets',
'auth.secrets',
ARRAY['node_id', 'secret_category', 'is_active', 'key_name']
);
Implementation Order (Today)
Phase 1: Database Schema (1-2 hours)
- Update db/203_auth_secrets.sql
- Add node_id column
- Add secret_category ltree column
- Migrate existing data
- Replace RLS policies
- Register cursor type
- Test schema changes locally
- Regenerate database types
Phase 2: Service Infrastructure (2-3 hours)
- Update
.cursorrules- add note about no migrations until deployment - Create
src/lib/auth/services/directory structure - Implement base patterns:
-
services/subject/- Graph subject operations - Zod schemas with z.uuid() and z.coerce.date()
- Operation functions following (client, params) pattern
- SubjectOperations class (always readonly)
-
Phase 3: Secret Service (3-4 hours)
- Create
services/secrets/models.ts- Use ltree for SecretCategory type
- GetSecretParams with node_id optional (for system secrets)
- CursorSecretParams (NO list params - only cursor)
- Use z.uuid() and z.coerce.date()
- Create
services/secrets/operations.ts- getSecret(client, params)
- querySecrets(client, params) - small result sets
- createSecret(client, params) - includes node_id
- updateSecret / deleteSecret / rotateSecret
- Create
services/secrets/ReadonlySecretOperations.ts- get() method with destructuring overload
- cursor() method with sensible defaults
- exists() check
- Create
services/secrets/TransactionalSecretOperations.ts- Extends readonly
- create(), update(), delete(), rotate()
Phase 4: Root Service & Integration (2-3 hours)
- Create
src/lib/auth/service.ts- AuthService class
- secrets() returns ReadonlySecretOperations
- subject() returns SubjectOperations
- transaction() with savepoint detection
- Create
src/lib/auth/TransactionalAuthService.ts- secrets() returns TransactionalSecretOperations
- Nested transaction support
- Update
src/lib/auth/index.tsto export AuthService - Write basic integration test
Phase 5: Documentation & Cleanup (1 hour)
- Update docs/auth-service-architecture.md
- Add usage examples
- Document graph-integrated approach
- Create migration guide from old API classes
Zod Schema Patterns
Use Modern Zod APIs
// ✅ CORRECT - Use z.uuid()
export const SecretIdSchema = z.string().uuid();
// Shorthand
export const SecretIdSchema = z.uuid();
// ✅ CORRECT - Use z.coerce.date() for dates
export const CreatedAtSchema = z.coerce.date();
// ✅ CORRECT - ltree as string
export const SecretCategorySchema = z.string(); // ltree stored as string
// ✅ CORRECT - nullable for optional node_id
export const NodeIdSchema = z.uuid().nullable();
Parameter Destructuring Support
// Support both object and destructured params
export class ReadonlySecretOperations {
// Object params (required)
async get(params: GetSecretParams): Promise<GetSecretResult | null>;
// Destructured convenience (for 1-2 params)
async get(nodeId: string, category: string, name: string): Promise<GetSecretResult | null>;
// Implementation
async get(
paramsOrNodeId: GetSecretParams | string,
category?: string,
name?: string
): Promise<GetSecretResult | null> {
const params = typeof paramsOrNodeId === 'string'
? { nodeId: paramsOrNodeId, category: category!, name: name! }
: paramsOrNodeId;
const validated = GetSecretParamsSchema.parse(params);
return operations.getSecret(this.client, validated);
}
}
Secret Category Taxonomy
Using ltree for flexible hierarchy:
// Example categories (ltree paths)
'system.mek' // Root MEK
'system.kek' // Key encryption key
'app.jwt.signing' // Application JWT signing
'app.encryption' // Application encryption
'user.api_key' // Per-user API keys
'user.oauth.github' // User OAuth for GitHub
'user.oauth.google' // User OAuth for Google
'org.api_key' // Per-org API keys
'org.integration.stripe' // Org Stripe integration
'org.integration.aws' // Org AWS integration
Benefits:
- Hierarchical queries:
secret_category <@ 'user.oauth' - Pattern matching:
secret_category ~ 'user.*' - Extensible without schema changes
- Similar to graph node_type_id pattern
Timeline: ~8-10 hours total
Morning (9am-1pm): 4 hours
- Phase 1: Database schema (2h)
- Phase 2: Service infrastructure (2h)
Afternoon (2pm-6pm): 4 hours
- Phase 3: Secret service (3h)
- Phase 4: Root service (1h)
Evening if needed (7pm-9pm): 2 hours
- Phase 5: Documentation & cleanup (1h)
- Buffer for testing & fixes (1h)
Testing Strategy
- Unit Tests - Test operation functions with mocked client
- Integration Tests - Test services with real database
- RLS Tests - Verify graph-based access control works
- Cursor Tests - Verify pagination works correctly
Success Criteria
- ✅ Database schema updated with node_id and ltree categories
- ✅ RLS policies use graph access functions
- ✅ SubjectOperations and SecretOperations implemented
- ✅ AuthService provides unified interface
- ✅ Transaction management works with savepoints
- ✅ Cursor pagination works for secrets
- ✅ Tests pass
- ✅ Documentation updated
Notes
- No backward compatibility - Fresh start, clean implementation
- Graph-first - Secrets follow graph ownership model
- System secrets - MEK/KEK remain special-cased (node_id = NULL)
- ltree taxonomy - Flexible, extensible, queryable
- Cursor-only lists - No unbounded queries
- Modern Zod - Use z.uuid(), z.coerce.date()