-- User Credentials Storage with RLS Policies -- This migration adds the user_credentials table for secure storage of user API keys, -- OAuth tokens, and other credentials with encryption and RLS enforcement. -- -- Related: #355 - Create UserCredential Prisma model with RLS policies -- Design: docs/design/credential-security.md (Phase 3a) -- ============================================================================= -- CREATE ENUMS -- ============================================================================= -- CredentialType enum: Types of credentials that can be stored CREATE TYPE "CredentialType" AS ENUM ('API_KEY', 'OAUTH_TOKEN', 'ACCESS_TOKEN', 'SECRET', 'PASSWORD', 'CUSTOM'); -- CredentialScope enum: Access scope for credentials CREATE TYPE "CredentialScope" AS ENUM ('USER', 'WORKSPACE', 'SYSTEM'); -- ============================================================================= -- EXTEND EXISTING ENUMS -- ============================================================================= -- Add CREDENTIAL to EntityType for activity logging ALTER TYPE "EntityType" ADD VALUE 'CREDENTIAL'; -- Add credential-related actions to ActivityAction ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_CREATED'; ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_ACCESSED'; ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_ROTATED'; ALTER TYPE "ActivityAction" ADD VALUE 'CREDENTIAL_REVOKED'; -- ============================================================================= -- CREATE USER_CREDENTIALS TABLE -- ============================================================================= CREATE TABLE "user_credentials" ( "id" UUID NOT NULL DEFAULT uuid_generate_v4(), "user_id" UUID NOT NULL, "workspace_id" UUID, -- Identity "name" VARCHAR(255) NOT NULL, "provider" VARCHAR(100) NOT NULL, "type" "CredentialType" NOT NULL, "scope" "CredentialScope" NOT NULL DEFAULT 'USER', -- Encrypted storage "encrypted_value" TEXT NOT NULL, "masked_value" VARCHAR(20), -- Metadata "description" TEXT, "expires_at" TIMESTAMPTZ, "last_used_at" TIMESTAMPTZ, "metadata" JSONB NOT NULL DEFAULT '{}', -- Status "is_active" BOOLEAN NOT NULL DEFAULT true, "rotated_at" TIMESTAMPTZ, -- Audit "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT "user_credentials_pkey" PRIMARY KEY ("id") ); -- ============================================================================= -- CREATE FOREIGN KEY CONSTRAINTS -- ============================================================================= ALTER TABLE "user_credentials" ADD CONSTRAINT "user_credentials_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "user_credentials" ADD CONSTRAINT "user_credentials_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- ============================================================================= -- CREATE INDEXES -- ============================================================================= -- Index for user lookups CREATE INDEX "user_credentials_user_id_idx" ON "user_credentials"("user_id"); -- Index for workspace lookups CREATE INDEX "user_credentials_workspace_id_idx" ON "user_credentials"("workspace_id"); -- Index for user + scope queries CREATE INDEX "user_credentials_user_id_scope_idx" ON "user_credentials"("user_id", "scope"); -- Index for workspace + scope queries CREATE INDEX "user_credentials_workspace_id_scope_idx" ON "user_credentials"("workspace_id", "scope"); -- Index for scope + active status queries CREATE INDEX "user_credentials_scope_is_active_idx" ON "user_credentials"("scope", "is_active"); -- ============================================================================= -- CREATE UNIQUE CONSTRAINT -- ============================================================================= -- Prevent duplicate credentials per user/workspace/provider/name CREATE UNIQUE INDEX "user_credentials_user_id_workspace_id_provider_name_key" ON "user_credentials"("user_id", "workspace_id", "provider", "name"); -- ============================================================================= -- ENABLE FORCE ROW LEVEL SECURITY -- ============================================================================= -- FORCE means the table owner (mosaic) is also subject to RLS policies. -- This prevents Prisma (connecting as owner) from bypassing policies. ALTER TABLE user_credentials ENABLE ROW LEVEL SECURITY; ALTER TABLE user_credentials FORCE ROW LEVEL SECURITY; -- ============================================================================= -- RLS POLICIES -- ============================================================================= -- Owner bypass policy: Allow access to all rows ONLY when no RLS context is set -- This is required for: -- 1. Prisma migrations that run without RLS context -- 2. Database maintenance operations -- When RLS context IS set (current_user_id() returns non-NULL), this policy does not apply -- -- NOTE: If connecting as a PostgreSQL superuser (like the default 'mosaic' role), -- RLS policies are bypassed entirely. For full RLS enforcement, the application -- should connect as a non-superuser role. See docs/design/credential-security.md CREATE POLICY user_credentials_owner_bypass ON user_credentials FOR ALL USING (current_user_id() IS NULL); -- User access policy: USER-scoped credentials visible only to owner -- Uses current_user_id() helper from migration 20260129221004_add_rls_policies CREATE POLICY user_credentials_user_access ON user_credentials FOR ALL USING ( scope = 'USER' AND user_id = current_user_id() ); -- Workspace admin access policy: WORKSPACE-scoped credentials visible to workspace admins -- Uses is_workspace_admin() helper from migration 20260129221004_add_rls_policies CREATE POLICY user_credentials_workspace_access ON user_credentials FOR ALL USING ( scope = 'WORKSPACE' AND workspace_id IS NOT NULL AND is_workspace_admin(workspace_id, current_user_id()) ); -- SYSTEM-scoped credentials are only accessible via owner bypass policy -- (when current_user_id() IS NULL, which happens for admin operations) -- ============================================================================= -- AUDIT TRIGGER -- ============================================================================= -- Update updated_at timestamp on row changes CREATE OR REPLACE FUNCTION update_user_credentials_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER user_credentials_updated_at BEFORE UPDATE ON user_credentials FOR EACH ROW EXECUTE FUNCTION update_user_credentials_updated_at(); -- ============================================================================= -- NOTES -- ============================================================================= -- This migration creates the foundation for secure credential storage. -- The encrypted_value column stores ciphertext in one of two formats: -- -- 1. OpenBao Transit format (preferred): vault:v1:base64data -- 2. AES-256-GCM fallback format: iv:authTag:encrypted -- -- The VaultService (issue #353) handles encryption/decryption with automatic -- fallback to CryptoService when OpenBao is unavailable. -- -- RLS enforcement ensures: -- - USER scope: Only the credential owner can access -- - WORKSPACE scope: Only workspace admins can access -- - SYSTEM scope: Only accessible via admin/migration bypass