package accessruleaudit import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "sort" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) const ( SchemaVersion = "access-rule-audit/v1" MigrationBatch = "0102_access_rule_allow_whitelist_semantics" ) type Snapshot struct { SchemaVersion string `json:"schemaVersion"` GeneratedAt time.Time `json:"generatedAt"` SecretSafe bool `json:"secretSafe"` Live RuleSet `json:"live"` Archive *ArchiveManifest `json:"archive,omitempty"` } type RuleSet struct { Total int64 `json:"total"` SHA256 string `json:"sha256"` AllowCount int64 `json:"allowCount"` AllowSHA256 string `json:"allowSha256"` DenyCount int64 `json:"denyCount"` DenySHA256 string `json:"denySha256"` Counts []RuleCount `json:"counts"` } type RuleCount struct { SubjectType string `json:"subjectType"` Effect string `json:"effect"` ResourceType string `json:"resourceType"` Status string `json:"status"` Count int64 `json:"count"` } type ArchiveManifest struct { MigrationBatch string `json:"migrationBatch"` ManifestAllowCount int64 `json:"manifestAllowCount"` ManifestAllowSHA string `json:"manifestAllowSha256"` ArchivedAllowCount int64 `json:"archivedAllowCount"` ArchivedAllowSHA string `json:"archivedAllowSha256"` ManifestDenyCount int64 `json:"manifestDenyCount"` ManifestDenySHA string `json:"manifestDenySha256"` Consistent bool `json:"consistent"` } type ruleDigestRow struct { id string subjectType string subjectID string resourceType string resourceID string effect string priority string minPermissionLevel string conditions string metadata string status string createdEpoch string updatedEpoch string } func Export(ctx context.Context, pool *pgxpool.Pool, now time.Time) (Snapshot, error) { live, err := loadLiveRules(ctx, pool) if err != nil { return Snapshot{}, err } archive, err := loadArchiveManifest(ctx, pool) if err != nil { return Snapshot{}, err } if now.IsZero() { now = time.Now() } snapshot := Snapshot{ SchemaVersion: SchemaVersion, GeneratedAt: now.UTC(), SecretSafe: true, Live: live, Archive: archive, } if err := Validate(snapshot); err != nil { return Snapshot{}, err } return snapshot, nil } func Validate(snapshot Snapshot) error { if snapshot.SchemaVersion != SchemaVersion { return fmt.Errorf("unsupported access-rule audit schema %q", snapshot.SchemaVersion) } if !snapshot.SecretSafe { return errors.New("access-rule audit snapshot is not marked secret-safe") } for name, value := range map[string]string{ "live": snapshot.Live.SHA256, "allow": snapshot.Live.AllowSHA256, "deny": snapshot.Live.DenySHA256, } { if !validSHA256(value) { return fmt.Errorf("%s SHA-256 is invalid", name) } } if snapshot.Live.Total != snapshot.Live.AllowCount+snapshot.Live.DenyCount { return errors.New("live access-rule counts are inconsistent") } var groupedTotal int64 for _, count := range snapshot.Live.Counts { if count.SubjectType == "" || count.Effect == "" || count.ResourceType == "" || count.Status == "" || count.Count < 1 { return errors.New("access-rule grouped count is invalid") } groupedTotal += count.Count } if groupedTotal != snapshot.Live.Total { return errors.New("access-rule grouped counts do not match total") } if snapshot.Archive != nil { archive := snapshot.Archive if archive.MigrationBatch != MigrationBatch || !validSHA256(archive.ManifestAllowSHA) || !validSHA256(archive.ArchivedAllowSHA) || !validSHA256(archive.ManifestDenySHA) { return errors.New("access-rule archive manifest is invalid") } consistent := archive.ManifestAllowCount == archive.ArchivedAllowCount && archive.ManifestAllowSHA == archive.ArchivedAllowSHA if archive.Consistent != consistent { return errors.New("access-rule archive consistency marker is incorrect") } } return nil } func VerifyMigration(before Snapshot, after Snapshot) error { if err := Validate(before); err != nil { return fmt.Errorf("before snapshot: %w", err) } if err := Validate(after); err != nil { return fmt.Errorf("after snapshot: %w", err) } if after.Archive == nil || !after.Archive.Consistent { return errors.New("verified allow archive is unavailable") } if after.Live.AllowCount != 0 { return fmt.Errorf("live legacy allow count=%d, want 0", after.Live.AllowCount) } if before.Live.AllowCount != after.Archive.ManifestAllowCount || before.Live.AllowSHA256 != after.Archive.ManifestAllowSHA { return errors.New("pre-migration allow snapshot does not match archived manifest") } if before.Live.DenyCount != after.Live.DenyCount || before.Live.DenySHA256 != after.Live.DenySHA256 { return errors.New("deny rules changed during allow migration") } return nil } func Encode(snapshot Snapshot) ([]byte, error) { if err := Validate(snapshot); err != nil { return nil, err } payload, err := json.MarshalIndent(snapshot, "", " ") if err != nil { return nil, err } return append(payload, '\n'), nil } func Decode(payload []byte) (Snapshot, error) { decoder := json.NewDecoder(strings.NewReader(string(payload))) decoder.DisallowUnknownFields() var snapshot Snapshot if err := decoder.Decode(&snapshot); err != nil { return Snapshot{}, err } if err := Validate(snapshot); err != nil { return Snapshot{}, err } return snapshot, nil } func loadLiveRules(ctx context.Context, pool *pgxpool.Pool) (RuleSet, error) { rows, err := pool.Query(ctx, ` SELECT id::text, subject_type, subject_id::text, resource_type, resource_id::text, effect, priority::text, min_permission_level::text, conditions::text, metadata::text, status, extract(epoch FROM created_at)::text, extract(epoch FROM updated_at)::text FROM gateway_access_rules ORDER BY id`) if err != nil { return RuleSet{}, err } defer rows.Close() allDigests := make([]string, 0) allowDigests := make([]string, 0) denyDigests := make([]string, 0) counts := map[string]int64{} for rows.Next() { var row ruleDigestRow if err := rows.Scan( &row.id, &row.subjectType, &row.subjectID, &row.resourceType, &row.resourceID, &row.effect, &row.priority, &row.minPermissionLevel, &row.conditions, &row.metadata, &row.status, &row.createdEpoch, &row.updatedEpoch, ); err != nil { return RuleSet{}, err } digest := rowSHA256(row) allDigests = append(allDigests, digest) if row.effect == "allow" { allowDigests = append(allowDigests, digest) } if row.effect == "deny" { denyDigests = append(denyDigests, digest) } counts[strings.Join([]string{row.subjectType, row.effect, row.resourceType, row.status}, "\x00")]++ } if err := rows.Err(); err != nil { return RuleSet{}, err } grouped := make([]RuleCount, 0, len(counts)) for key, count := range counts { parts := strings.Split(key, "\x00") grouped = append(grouped, RuleCount{SubjectType: parts[0], Effect: parts[1], ResourceType: parts[2], Status: parts[3], Count: count}) } sort.Slice(grouped, func(i, j int) bool { left := grouped[i] right := grouped[j] return strings.Join([]string{left.SubjectType, left.Effect, left.ResourceType, left.Status}, "\x00") < strings.Join([]string{right.SubjectType, right.Effect, right.ResourceType, right.Status}, "\x00") }) return RuleSet{ Total: int64(len(allDigests)), SHA256: aggregateSHA256(allDigests), AllowCount: int64(len(allowDigests)), AllowSHA256: aggregateSHA256(allowDigests), DenyCount: int64(len(denyDigests)), DenySHA256: aggregateSHA256(denyDigests), Counts: grouped, }, nil } func loadArchiveManifest(ctx context.Context, pool *pgxpool.Pool) (*ArchiveManifest, error) { var available bool if err := pool.QueryRow(ctx, ` SELECT to_regclass('gateway_access_rule_migration_batches') IS NOT NULL AND to_regclass('gateway_access_rule_allow_archive') IS NOT NULL`).Scan(&available); err != nil { return nil, err } if !available { return nil, nil } manifest := &ArchiveManifest{MigrationBatch: MigrationBatch} if err := pool.QueryRow(ctx, ` SELECT allow_count, allow_sha256, deny_count, deny_sha256 FROM gateway_access_rule_migration_batches WHERE migration_batch = $1`, MigrationBatch).Scan( &manifest.ManifestAllowCount, &manifest.ManifestAllowSHA, &manifest.ManifestDenyCount, &manifest.ManifestDenySHA, ); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, err } rows, err := pool.Query(ctx, ` SELECT row_sha256 FROM gateway_access_rule_allow_archive WHERE migration_batch = $1 ORDER BY rule_id`, MigrationBatch) if err != nil { return nil, err } defer rows.Close() digests := make([]string, 0) for rows.Next() { var digest string if err := rows.Scan(&digest); err != nil { return nil, err } digests = append(digests, digest) } if err := rows.Err(); err != nil { return nil, err } manifest.ArchivedAllowCount = int64(len(digests)) manifest.ArchivedAllowSHA = aggregateSHA256(digests) manifest.Consistent = manifest.ManifestAllowCount == manifest.ArchivedAllowCount && manifest.ManifestAllowSHA == manifest.ArchivedAllowSHA return manifest, nil } func rowSHA256(row ruleDigestRow) string { return sha256Hex(strings.Join([]string{ row.id, row.subjectType, row.subjectID, row.resourceType, row.resourceID, row.effect, row.priority, row.minPermissionLevel, row.conditions, row.metadata, row.status, row.createdEpoch, row.updatedEpoch, }, "\x1f")) } func aggregateSHA256(rowDigests []string) string { return sha256Hex(strings.Join(rowDigests, "\n")) } func sha256Hex(value string) string { digest := sha256.Sum256([]byte(value)) return hex.EncodeToString(digest[:]) } func validSHA256(value string) bool { if len(value) != 64 { return false } _, err := hex.DecodeString(value) return err == nil && value == strings.ToLower(value) }