Savvi Studio

Auth Service Implementation Plan - TODAY

Revised Architecture Based on Feedback

Key Decisions

  1. No New Table - Extend existing auth.secrets with graph node ownership
  2. No Migrations - Modify existing SQL files directly (app not deployed)
  3. Graph Integration - Secrets owned by graph nodes, use existing RLS patterns
  4. System Secrets - Root KEK/MEK remain system-owned (no node_id)
  5. Cursor Only - Remove list(), only use cursor() with sensible defaults
  6. 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.ts to 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

  1. Unit Tests - Test operation functions with mocked client
  2. Integration Tests - Test services with real database
  3. RLS Tests - Verify graph-based access control works
  4. 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()