diff --git a/internal/authz/reconcile.go b/internal/authz/reconcile.go new file mode 100644 index 00000000..fad9ce2b --- /dev/null +++ b/internal/authz/reconcile.go @@ -0,0 +1,190 @@ +package authz + +import ( + "context" + "errors" + "fmt" + "io/fs" + "sort" + + "github.com/compliance-framework/api/internal/service/relational" + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// roleAssignmentKey is the (assigneeType, assigneeId, roleName) triple the table's unique index is +// built on — the identity of a single grant, exactly as the precedence rule defines it ("a grant is +// config OR manual, keyed by the triple"). Reconciliation compares grants by this key, never by +// principal, so a principal may hold a config grant for one role and a manual grant for another. +type roleAssignmentKey struct { + assigneeType string + assigneeID string + role string +} + +func keyOf(a relational.CCFRoleAssignment) roleAssignmentKey { + return roleAssignmentKey{assigneeType: a.AssigneeType, assigneeID: a.AssigneeID, role: a.RoleName} +} + +// configGrants returns the user/group role grants the file declares as (type,id,role) triples, with +// ids normalized to lower-case (matching how the table stores and looks them up), sorted for +// deterministic creation and logging. The agents/anonymous scalar defaults are intentionally +// excluded: they are not table-backed and stay config-sourced in the resolver (the BCH-1333 +// decision). normalize() must have run first, so map keys are already folded. +func (ra *RoleAssignments) configGrants() []roleAssignmentKey { + grants := make([]roleAssignmentKey, 0, len(ra.Users)+len(ra.Groups)) + add := func(assigneeType, principal, role string) { + if role == "" { + return + } + grants = append(grants, roleAssignmentKey{ + assigneeType: assigneeType, + assigneeID: relational.NormalizeAssigneeID(principal), + role: role, + }) + } + for user, role := range ra.Users { + add(relational.RoleAssigneeTypeUser, user, role) + } + for group, role := range ra.Groups { + add(relational.RoleAssigneeTypeGroup, group, role) + } + sort.Slice(grants, func(i, j int) bool { + if grants[i].assigneeType != grants[j].assigneeType { + return grants[i].assigneeType < grants[j].assigneeType + } + if grants[i].assigneeID != grants[j].assigneeID { + return grants[i].assigneeID < grants[j].assigneeID + } + return grants[i].role < grants[j].role + }) + return grants +} + +// ReconcileConfigRoleAssignments makes the persisted ccf_role_assignments table reflect the +// user/group grants declared in the role-assignment config file (authz-roles.yaml), marking them +// source=config (BCH-1334). With BCH-1333 the table is the PDP's source of truth, so the file stops +// being read per request and is instead a boot seed reconciled into the table here. +// +// Reconcile, don't duplicate: +// - a config grant that already matches is left untouched (no write); +// - a grant the file no longer declares is deleted (source=config rows only); +// - a manual row whose (type,id,role) the file now declares is adopted as config — precedence: +// config ownership wins for an identical grant, so the admin API's 409-on-config-delete protects +// it and the file/API never fight over the same row; +// - source=manual rows the file does not declare are never touched. +// +// It is idempotent: re-running the same file produces zero writes and no duplicate rows. A missing +// file is "no config grants" — every source=config row is removed — so deleting the file revokes its +// grants on the next boot. A malformed file, or a role the manifest does not declare, is a hard error +// that blocks startup (the caller treats a migration error as fatal), preserving the fail-fast +// validate() behaviour the cedar loader had. It runs for every driver so the table, and the admin UI +// built on it, reflect config regardless of which engine enforces it. +func ReconcileConfigRoleAssignments(ctx context.Context, db *gorm.DB, path string, logger *zap.SugaredLogger) error { + if logger == nil { + logger = zap.NewNop().Sugar() + } + if db == nil { + return nil + } + + assignments := &RoleAssignments{} + if path != "" { + loaded, err := LoadRoleAssignments(path) + switch { + case err == nil: + assignments = loaded + case errors.Is(err, fs.ErrNotExist): + logger.Infow("authz reconcile: role-assignment file not found; removing any config-owned grants", "path", path) + default: + return err + } + } + assignments.normalize() + + m, err := DefaultManifest() + if err != nil { + return fmt.Errorf("authz reconcile: load manifest: %w", err) + } + if err := assignments.validate(m); err != nil { + return err + } + + desired := assignments.configGrants() + desiredSet := make(map[roleAssignmentKey]struct{}, len(desired)) + for _, want := range desired { + desiredSet[want] = struct{}{} + } + + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing []relational.CCFRoleAssignment + if err := tx.Find(&existing).Error; err != nil { + return err + } + byTriple := make(map[roleAssignmentKey]relational.CCFRoleAssignment, len(existing)) + for _, row := range existing { + byTriple[keyOf(row)] = row + } + + var created, adopted, deleted int + for _, want := range desired { + row, ok := byTriple[want] + switch { + case !ok: + // OnConflict DoNothing keeps the insert idempotent under concurrent boots: this + // reconcile runs on every replica's startup (MigrateUpWithConfig, fatal on error), so in + // an HA/rolling deploy two replicas can both see the triple as absent and race to insert + // it. Without this, the loser's commit raises a duplicate-key violation that crashes the + // replica (CrashLoopBackOff) for a benign race; with it, the loser is a no-op. The unique + // index is (assignee_type, assignee_id, role_name). + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "assignee_type"}, + {Name: "assignee_id"}, + {Name: "role_name"}, + }, + DoNothing: true, + }).Create(&relational.CCFRoleAssignment{ + RoleName: want.role, + AssigneeType: want.assigneeType, + AssigneeID: want.assigneeID, + Source: relational.RoleAssignmentSourceConfig, + }).Error; err != nil { + return err + } + created++ + case row.Source != relational.RoleAssignmentSourceConfig: + // An identical manual grant already exists; config ownership wins, so adopt it as + // config rather than creating a duplicate (which the unique index would reject anyway). + if err := tx.Model(&relational.CCFRoleAssignment{}). + Where("id = ?", row.ID). + Update("source", relational.RoleAssignmentSourceConfig).Error; err != nil { + return err + } + adopted++ + } + // else: already source=config and matching — leave untouched (this is what makes a + // re-run of an unchanged file write nothing). + } + + for _, row := range existing { + if row.Source != relational.RoleAssignmentSourceConfig { + continue + } + if _, ok := desiredSet[keyOf(row)]; ok { + continue + } + if err := tx.Delete(&row).Error; err != nil { + return err + } + deleted++ + } + + if created+adopted+deleted > 0 { + logger.Infow("authz reconcile: synced config role assignments", + "created", created, "adopted", adopted, "deleted", deleted, "configGrants", len(desired)) + } + return nil + }) +} diff --git a/internal/authz/reconcile_integration_test.go b/internal/authz/reconcile_integration_test.go new file mode 100644 index 00000000..37b0366c --- /dev/null +++ b/internal/authz/reconcile_integration_test.go @@ -0,0 +1,193 @@ +//go:build integration + +package authz + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/compliance-framework/api/internal/service/relational" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func writeRolesFile(t *testing.T, dir, body string) string { + t.Helper() + path := filepath.Join(dir, "authz-roles.yaml") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +func allAssignments(t *testing.T, db *gorm.DB) []relational.CCFRoleAssignment { + t.Helper() + var rows []relational.CCFRoleAssignment + require.NoError(t, db.Order("assignee_type, assignee_id, role_name").Find(&rows).Error) + return rows +} + +func indexByTriple(rows []relational.CCFRoleAssignment) map[string]relational.CCFRoleAssignment { + out := make(map[string]relational.CCFRoleAssignment, len(rows)) + for _, r := range rows { + out[r.AssigneeType+"|"+r.AssigneeID+"|"+r.RoleName] = r + } + return out +} + +func idSet(rows []relational.CCFRoleAssignment) map[string]bool { + out := make(map[string]bool, len(rows)) + for _, r := range rows { + out[r.ID.String()] = true + } + return out +} + +// TestReconcileConfigRoleAssignments exercises the BCH-1334 boot reconcile: the role-assignment +// file becomes source=config rows in ccf_role_assignments (BCH-1333's source of truth), kept in +// sync across restarts without churn, and never touching admin-created manual grants. +func TestReconcileConfigRoleAssignments(t *testing.T) { + ctx := context.Background() + + t.Run("seeds config grants and is idempotent", func(t *testing.T) { + db := setupAuthzDB(t) + dir := t.TempDir() + // Mixed casing on purpose: ids are normalized to lower-case at write time. + path := writeRolesFile(t, dir, ` +users: + Alice@Example.com: auditor + bob@example.com: admin +groups: + Sec-Team: viewer +`) + require.NoError(t, ReconcileConfigRoleAssignments(ctx, db, path, nil)) + + rows := allAssignments(t, db) + require.Len(t, rows, 3) + for _, r := range rows { + require.Equal(t, relational.RoleAssignmentSourceConfig, r.Source) + } + byKey := indexByTriple(rows) + require.Contains(t, byKey, "user|alice@example.com|auditor") + require.Contains(t, byKey, "user|bob@example.com|admin") + require.Contains(t, byKey, "group|sec-team|viewer") + + // Re-running an unchanged file must write nothing: the same row ids survive (no + // delete+recreate) and no duplicates appear. + before := idSet(rows) + require.NoError(t, ReconcileConfigRoleAssignments(ctx, db, path, nil)) + after := allAssignments(t, db) + require.Len(t, after, 3) + require.Equal(t, before, idSet(after), "re-running an unchanged config must not rewrite rows") + }) + + t.Run("removes config grants dropped from the file, leaves manual untouched", func(t *testing.T) { + db := setupAuthzDB(t) + dir := t.TempDir() + path := writeRolesFile(t, dir, ` +users: + alice@example.com: auditor + bob@example.com: admin +`) + require.NoError(t, ReconcileConfigRoleAssignments(ctx, db, path, nil)) + + // An ad-hoc admin grant, not declared in config. + manual := &relational.CCFRoleAssignment{ + RoleName: "viewer", + AssigneeType: relational.RoleAssigneeTypeUser, + AssigneeID: "carol@example.com", + Source: relational.RoleAssignmentSourceManual, + } + require.NoError(t, db.Create(manual).Error) + + // Drop bob from the file and reconcile. + writeRolesFile(t, dir, ` +users: + alice@example.com: auditor +`) + require.NoError(t, ReconcileConfigRoleAssignments(ctx, db, path, nil)) + + byKey := indexByTriple(allAssignments(t, db)) + require.Contains(t, byKey, "user|alice@example.com|auditor") + require.NotContains(t, byKey, "user|bob@example.com|admin", "config grant dropped from the file must be deleted") + + survivor, ok := byKey["user|carol@example.com|viewer"] + require.True(t, ok, "manual grant must survive a reconcile") + require.Equal(t, relational.RoleAssignmentSourceManual, survivor.Source) + require.Equal(t, manual.ID.String(), survivor.ID.String(), "manual row left untouched") + }) + + t.Run("missing file removes config grants but keeps manual", func(t *testing.T) { + db := setupAuthzDB(t) + dir := t.TempDir() + path := writeRolesFile(t, dir, "users:\n alice@example.com: auditor\n") + require.NoError(t, ReconcileConfigRoleAssignments(ctx, db, path, nil)) + require.NoError(t, db.Create(&relational.CCFRoleAssignment{ + RoleName: "viewer", + AssigneeType: relational.RoleAssigneeTypeUser, + AssigneeID: "carol@example.com", + Source: relational.RoleAssignmentSourceManual, + }).Error) + + require.NoError(t, os.Remove(path)) + require.NoError(t, ReconcileConfigRoleAssignments(ctx, db, path, nil)) + + rows := allAssignments(t, db) + require.Len(t, rows, 1) + require.Equal(t, relational.RoleAssignmentSourceManual, rows[0].Source) + require.Equal(t, "carol@example.com", rows[0].AssigneeID) + }) + + t.Run("adopts an identical manual grant as config (precedence)", func(t *testing.T) { + db := setupAuthzDB(t) + dir := t.TempDir() + // Admin manually grants alice auditor before config declares the same triple. + manual := &relational.CCFRoleAssignment{ + RoleName: "auditor", + AssigneeType: relational.RoleAssigneeTypeUser, + AssigneeID: "alice@example.com", + Source: relational.RoleAssignmentSourceManual, + } + require.NoError(t, db.Create(manual).Error) + + path := writeRolesFile(t, dir, "users:\n alice@example.com: auditor\n") + require.NoError(t, ReconcileConfigRoleAssignments(ctx, db, path, nil)) + + rows := allAssignments(t, db) + require.Len(t, rows, 1, "an identical grant must not be duplicated") + require.Equal(t, relational.RoleAssignmentSourceConfig, rows[0].Source, "config ownership wins for an identical grant") + require.Equal(t, manual.ID.String(), rows[0].ID.String(), "the existing row is adopted in place, not replaced") + }) + + t.Run("replaces a changed role for a principal (delete old + create new)", func(t *testing.T) { + db := setupAuthzDB(t) + dir := t.TempDir() + path := writeRolesFile(t, dir, "users:\n alice@example.com: auditor\n") + require.NoError(t, ReconcileConfigRoleAssignments(ctx, db, path, nil)) + + before := allAssignments(t, db) + require.Len(t, before, 1) + oldID := before[0].ID.String() + + writeRolesFile(t, dir, "users:\n alice@example.com: admin\n") + require.NoError(t, ReconcileConfigRoleAssignments(ctx, db, path, nil)) + + rows := allAssignments(t, db) + require.Len(t, rows, 1, "alice still has exactly one config grant after the role change") + require.Equal(t, "admin", rows[0].RoleName) + require.Equal(t, relational.RoleAssignmentSourceConfig, rows[0].Source) + // The role is part of the unique triple, so a role change is delete(old triple)+create(new), + // not an in-place row update: the row identity changes. (Contrast the adopt test, which keeps + // the same id because the triple is unchanged.) + require.NotEqual(t, oldID, rows[0].ID.String(), "a role change replaces the row rather than updating it in place") + }) + + t.Run("a file referencing an unknown role fails fast", func(t *testing.T) { + db := setupAuthzDB(t) + dir := t.TempDir() + path := writeRolesFile(t, dir, "users:\n alice@example.com: wizard\n") + require.Error(t, ReconcileConfigRoleAssignments(ctx, db, path, nil), "an unknown role must block startup") + + require.Empty(t, allAssignments(t, db), "a rejected file must not write any rows") + }) +} diff --git a/internal/service/migrator.go b/internal/service/migrator.go index b4c8485c..45a00cc2 100644 --- a/internal/service/migrator.go +++ b/internal/service/migrator.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/compliance-framework/api/internal/authz" "github.com/compliance-framework/api/internal/config" "github.com/compliance-framework/api/internal/service/notification" slackprovider "github.com/compliance-framework/api/internal/service/notification/providers/slack" @@ -415,6 +416,17 @@ func MigrateUpWithConfig(db *gorm.DB, cfg *config.Config) error { // They will rely on their default query plans; a plain index on control_id // is typically not used for expression predicates like UPPER(control_id) without an expression index. + // Reconcile the role-assignment config file into the persisted ccf_role_assignments table + // (BCH-1334). With BCH-1333 the table is the PDP's source of truth, so authz-roles.yaml is a + // boot seed: its user/group grants are upserted as source=config and stale config grants removed, + // before the server serves traffic. A bad/typo'd file fails fast here (the caller treats a + // migration error as fatal). Runs whenever authz config is present; MigrateUp(db, nil) skips it. + if cfg != nil && cfg.Authz != nil { + if err := authz.ReconcileConfigRoleAssignments(context.Background(), db, cfg.Authz.RoleAssignmentsPath, nil); err != nil { + return err + } + } + return err }