Zum Inhalt springen
Farbschema wählenSprache wählen

Eine Credential-Art hinzufügen

Diese Anleitung beschreibt die vollständige Checkliste zum Hinzufügen einer neuen Credential-Art, die durch den Postgres-AES-GCM-Envelope gesichert wird — dasselbe Muster wie bei api_key_refs, git_credentials, claude_oauth_tokens, tracker_oauth_credentials und env_vars.

Wenn das geheime Material in OpenBao liegt (ein Vault-Pfad oder KV2-Referenz), handelt es sich um eine OpenBao-Ref-Art (resources, mailbox_credentials). Diese Art ist hier nicht behandelt — siehe services::secret_scope::kind_backend und ADR 0038 D6.

Jede Postgres-Envelope-Credential-Zeile gehört genau einer Scope-Stufe an, die durch eine XOR-CHECK-Bedingung (Migration 156) erzwungen und in CredentialScope (persistence/db/models/credential_scope.rs) kodiert wird:

Scope workspace_id organization_id Gewinnt wenn
Personal NULL NULL standardmäßig am spezifischsten
Workspace gesetzt NULL überschreibt Org
Organisation NULL gesetzt standardmäßig letzter Rang; gewinnt bei locked = true

Die Auflösungsregel aus services::secret_scope::resolve_uniform:

if org[name].locked → Org gewinnt (org-shared-wins Kurzschluss)
else → personal > workspace > org (most-specific-wins)

Die Auflösung findet in SQL per ORDER BY CASE … LIMIT 1 statt — die Datenbank gibt den einzelnen Gewinner zurück, niemals eine vollständige Kandidatenliste.

Das locked-Flag (Migration 164) ist nur bei Org-Zeilen sinnvoll. Eine Org-Credential mit locked = true überschreibt jede Workspace- oder Personal-Credential desselben logischen Schlüssels und ermöglicht so Abrechnungs- und Policy-Enforcement-Anwendungsfälle ohne eine eigene Vorrangregel pro Art.

  1. Migration schreiben.

    Ermittle zunächst die aktuell höchste Migrationsnummer — der Namespace ist zwischen parallelen Entwicklern und Worktrees geteilt. Die Abfrage-Anleitung findest du unter Eine Datenbankmigration hinzufügen.

    Die Tabelle muss die drei Scope-Spalten, die XOR-CHECK-Bedingung, das locked-Flag und org-bewusste RLS-Policies enthalten. Verwende api_key_refs und die Migrationen 156 und 164 als Vorlage:

    CREATE TABLE my_credentials (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE,
    organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
    locked BOOLEAN NOT NULL DEFAULT false,
    secret_data TEXT NOT NULL, -- enthält scenc:v1: Geheimtext
    -- ... deine art-spezifischen Spalten ...
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CONSTRAINT my_credentials_scope_xor_team_org
    CHECK (workspace_id IS NULL OR organization_id IS NULL)
    );
    -- Index für org-scoped Auflösung
    CREATE INDEX idx_my_credentials_org
    ON my_credentials (organization_id)
    WHERE organization_id IS NOT NULL;
    -- RLS: Lesen = jedes Mitglied; Schreiben = Workspace-Admin ODER Org-Owner/Admin
    ALTER TABLE my_credentials ENABLE ROW LEVEL SECURITY;
    CREATE POLICY my_credentials_select ON my_credentials FOR SELECT USING (
    user_id = (select auth.uid())
    OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = (select auth.uid()))
    OR organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = (select auth.uid()))
    );
    CREATE POLICY my_credentials_insert ON my_credentials FOR INSERT WITH CHECK (
    (organization_id IS NULL AND user_id = (select auth.uid()))
    OR organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin'))
    );
    CREATE POLICY my_credentials_update ON my_credentials FOR UPDATE USING (
    user_id = (select auth.uid())
    OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin'))
    OR organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin'))
    );
    CREATE POLICY my_credentials_delete ON my_credentials FOR DELETE USING (
    user_id = (select auth.uid())
    OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin'))
    OR organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = (select auth.uid()) AND role IN ('owner', 'admin'))
    );
  2. Backend in secret_scope::kind_backend registrieren.

    Öffne server/src/services/secret_scope.rs und füge deinen Tabellennamen dem PostgresEnvelope-Arm hinzu:

    "api_key_refs"
    | "env_vars"
    | "git_credentials"
    | "claude_oauth_tokens"
    | "tracker_oauth_credentials"
    | "my_credentials" // das hier hinzufügen
    => Some(SecretBackend::PostgresEnvelope),

    Die kind_backend-Tests erkennen eine nicht registrierte oder falsch klassifizierte Art.

  3. Query-Schicht mit scope-bewusstem AAD hinzufügen.

    Erstelle server/src/persistence/db/queries/my_credentials.rs. Wähle einen tabellenqualifizierten AAD-Basisstring — "my_credentials.secret_data" — und verwende die Hilfsfunktionen protect_secret / reveal_secret aus queries::credential_secret:

    const SECRET_DATA_AAD: &str = "my_credentials.secret_data";
    pub async fn insert_my_credential(
    pool: &PgPool,
    user_id: Uuid,
    secret: &str,
    scope: CredentialScope,
    ) -> sqlx::Result<MyCredential> {
    let (workspace_id, org_id) = (scope.workspace_id(), scope.organization_id());
    let protected = protect_secret(secret, SECRET_DATA_AAD, workspace_id, org_id)?;
    sqlx::query_as::<_, MyCredential>(
    "INSERT INTO my_credentials (user_id, secret_data, workspace_id, organization_id)
    VALUES ($1, $2, $3, $4) RETURNING *",
    )
    .bind(user_id).bind(protected).bind(workspace_id).bind(org_id)
    .fetch_one(pool).await
    // beim Zurückgeben entschlüsseln:
    .and_then(reveal_my_credential)
    }
    fn reveal_my_credential(mut row: MyCredential) -> sqlx::Result<MyCredential> {
    row.secret_data = reveal_secret(
    &row.secret_data,
    SECRET_DATA_AAD,
    row.workspace_id,
    row.organization_id,
    )?;
    Ok(row)
    }

    Die Funktion scoped_aad in app/db_secrets.rs leitet den AAD wie folgt ab:

    • Workspace-Zeile: my_credentials.secret_data.workspace.<workspace_id>
    • Org-Zeile: my_credentials.secret_data.organization.<org_id>
    • Personal-Zeile: my_credentials.secret_data.user

    Die AES-GCM-Authentifizierung schlägt fehl, wenn ein Geheimtext in eine Zeile mit einer anderen Mandanten-ID verschoben wird — das ist der Cross-Tenant-Relokationsschutz aus ADR 0038 D7.

  4. Single-Winner-Resolver schreiben.

    Für die punktgenaue Auflösung (eine Credential per logischem Schlüssel abrufen) spiegle das ORDER BY CASE … LIMIT 1-Muster. Die Ränge stammen aus secret_scope::case_rank:

    SELECT *
    FROM my_credentials
    WHERE user_id = $user -- persönliche Kandidaten
    OR workspace_id = $ws
    OR organization_id = $org
    ORDER BY
    CASE
    WHEN organization_id IS NOT NULL AND locked THEN 0
    WHEN user_id = $user AND workspace_id IS NULL AND organization_id IS NULL THEN 1
    WHEN workspace_id IS NOT NULL THEN 2
    ELSE 3 -- entsperrte Org
    END
    LIMIT 1

    Die Unit-Tests in secret_scope prüfen, dass resolve_uniform und case_rank übereinstimmen, sodass jede Abweichung zwischen diesem SQL und der Spezifikation im CI erkannt wird.

  5. Schreibzugriffe nach Scope-Berechtigung absichern.

    • Personal- / Workspace-Zeilen: jede authentifizierte Nutzerin / jeder authentifizierte Nutzer darf in eigene Personal-Zeilen schreiben; für Workspace-Zeilen ist WorkspaceAction::ManageCredentials (oder die Workspace-Admin-Rolle) erforderlich.
    • Organisations-Zeilen: erfordern OrgAction::ManageCredentials (Org-owner oder admin). Der Management-API-State-Apply-Writer muss workspace-eingeschränkt bleiben — er darf niemals eine org-scoped Zeile schreiben (ADR 0038 D3 Anti-Kontamination).
    • locked-Toggle: nur für Org-Zeilen; erfordert Org-Admin-Berechtigung. Füge eine Query analog zu set_api_key_locked in queries/credentials.rs hinzu.
  6. Verifizieren.

    Terminal window
    cd server && cargo check
    cargo test arch
    cargo test secret_scope

    cargo test arch erzwingt die 500-Zeilen-Quelldatei-Obergrenze und die Modulgrenzen-Regeln. cargo test secret_scope führt den resolve_uniform / case_rank-Übereinstimmungstest aus.

  • server/src/services/secret_scope.rs — kanonische Resolver-Spezifikation
  • server/src/app/db_secrets.rsscoped_aad, protect_scoped, reveal_scoped
  • server/src/persistence/db/models/credential_scope.rsCredentialScope-Enum
  • server/migrations/156_credential_org_scope.sql — XOR-CHECK-Vorlage
  • server/migrations/164_credential_locked_and_org_rls.sqllocked + RLS-Vorlage
  • ADR 0038 — Begründung des einheitlichen dreistufigen Scopes
  • Eine Datenbankmigration hinzufügen