Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions migrations/040_service_keys_fk_cascade.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- 040_service_keys_fk_cascade.down.sql
-- Reverses 040_service_keys_fk_cascade.up.sql
-- Flips the FK back to ON DELETE NO ACTION (the implicit default when no
-- ON DELETE clause is specified — the presumed pre-migration-006 legacy
-- shape). Verify against a dev1 snapshot before relying on this in a real
-- rollback; if the legacy constraint turns out to have had a different
-- ON DELETE behaviour, update this file to match before use.

ALTER TABLE service_keys
DROP CONSTRAINT IF EXISTS service_keys_identity_id_fkey;

ALTER TABLE service_keys
ADD CONSTRAINT service_keys_identity_id_fkey
FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE NO ACTION;
15 changes: 15 additions & 0 deletions migrations/040_service_keys_fk_cascade.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- 040_service_keys_fk_cascade.up.sql
-- Reconcile service_keys.identity_id FK to match migration 006's declared
-- ON DELETE CASCADE on legacy deployments where CREATE TABLE IF NOT EXISTS
-- was a no-op (the table predates the cascade and the constraint never got
-- re-applied). Fresh deployments are a no-op (constraint flip to identical
-- shape). See highflame-authn#109 / zeroid#187 for the user-visible incident
-- this drift originally produced, and zeroid#196 for the schema-drift root
-- cause this migration closes.

ALTER TABLE service_keys
DROP CONSTRAINT IF EXISTS service_keys_identity_id_fkey;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Missing transaction safety

While both operations target the same table, running DDL statements without an explicit transaction block could leave the table in an inconsistent state if the ADD CONSTRAINT fails after the DROP succeeds (e.g., if identities table is locked or validation finds violations). Wrap in BEGIN; ... COMMIT; to ensure atomicity.

Suggested fix:

Suggested change
BEGIN;
ALTER TABLE service_keys
DROP CONSTRAINT IF EXISTS service_keys_identity_id_fkey;
ALTER TABLE service_keys
ADD CONSTRAINT service_keys_identity_id_fkey
FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE CASCADE;
COMMIT;

ALTER TABLE service_keys
ADD CONSTRAINT service_keys_identity_id_fkey
FOREIGN KEY (identity_id) REFERENCES identities(id) ON DELETE CASCADE;
58 changes: 58 additions & 0 deletions tests/integration/service_keys_fk_cascade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package integration_test

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/highflame-ai/zeroid/domain"
)

// TestServiceKeysFKCascade_HardDeleteIdentity_CascadesKey is regression
// coverage for zeroid#196: migration 006 declares
// service_keys.identity_id REFERENCES identities(id) ON DELETE CASCADE, but
// CREATE TABLE IF NOT EXISTS is a no-op on any deployment where the table
// pre-existed the cascade — leaving legacy DBs with a non-cascading FK (the
// root cause of highflame-authn#109 / zeroid#187). Migration
// 040_service_keys_fk_cascade explicitly re-applies the cascade on every
// startup so all deployments converge on the declared shape.
//
// This test proves the FK itself (not just the current lucky call-ordering
// inside PurgeIdentity, which today deletes the identity before any service
// key row exists) permits a hard delete of a service-key-bearing identity to
// cascade cleanly. That's the invariant PurgeIdentity's compensating
// rollback silently depends on, and that any future hard-delete caller
// (e.g. a GDPR-erasure path) will need too.
func TestServiceKeysFKCascade_HardDeleteIdentity_CascadesKey(t *testing.T) {
ctx := context.Background()
reg := registerAgent(t, uid("fk-cascade")) // auto-creates a bootstrap service key

// Precondition: exactly one service key row references this identity.
n, err := testDB.NewSelect().
Model((*domain.APIKey)(nil)).
Where("identity_id = ?", reg.AgentID).
Count(ctx)
require.NoError(t, err)
require.Equal(t, 1, n, "precondition: registerAgent must create exactly one service key")

// Hard-delete the identity directly at the repo layer — the same
// DELETE FROM identities ... that IdentityRepository.Delete (and
// therefore PurgeIdentity) issues. Before the fix, this trips "violates
// foreign key constraint service_keys_identity_id_fkey" on any DB where
// the table predates the cascade.
_, err = testDB.NewDelete().
Model((*domain.Identity)(nil)).
Where("id = ?", reg.AgentID).
Exec(ctx)
require.NoError(t, err, "hard delete of a service-key-bearing identity must cascade, not FK-violate")

// The service key must have been cascade-deleted along with its parent.
n, err = testDB.NewSelect().
Model((*domain.APIKey)(nil)).
Where("identity_id = ?", reg.AgentID).
Count(ctx)
require.NoError(t, err)
assert.Equal(t, 0, n, "service_keys row must cascade-delete with its parent identity")
}