diff --git a/examples/caring/registry_snapshot.json b/examples/caring/registry_snapshot.json new file mode 100644 index 0000000..3af5a8b --- /dev/null +++ b/examples/caring/registry_snapshot.json @@ -0,0 +1,99 @@ +{ + "systems": [ + { + "id": "markitect-tool", + "name": "Markitect Tool", + "resource_types": [ + { + "name": "document", + "scope_level": "Resource", + "planes": ["Data"] + } + ], + "actions": [ + { + "name": "read", + "capabilities": ["View"], + "planes": ["Data"], + "exposure_modes": ["Masked", "Plaintext"] + } + ], + "caring_profiles": ["caring-0.4.0-rc2"] + } + ], + "resource_manifests": [ + { + "id": "markitect-example-knowledge-base", + "system": "markitect-tool", + "resources": [ + { + "id": "document:internal-note", + "type": "document", + "parent": "knowledge-base:markitect-example", + "labels": ["internal"], + "trust_zone": "internal", + "owner": "team:platform" + } + ], + "actions": ["read", "query", "search", "package", "export"], + "caring_profile": "caring-0.4.0-rc2", + "metadata": { + "flex_auth_contract": "resource-registration-v0" + } + } + ], + "tenants": [ + { + "id": "tenant:alpha", + "name": "Tenant Alpha" + } + ], + "subjects": [ + { + "id": "user:alice", + "type": "Human", + "display_name": "Alice Example", + "organization_relation": "Customer", + "roles": ["Doer"], + "groups": ["group:platform-architecture"], + "tenant": "tenant:alpha" + } + ], + "groups": [ + { + "id": "group:platform-architecture", + "display_name": "Platform Architecture", + "members": ["user:alice"], + "tenant": "tenant:alpha" + } + ], + "relationships": [ + { + "id": "rel:alice-reader-internal-note", + "system": "markitect-tool", + "subject": "group:platform-architecture", + "relation": "reader", + "object": "document:internal-note", + "tenant": "tenant:alpha", + "conditions": ["Logged"], + "caring": { + "id": "descriptor:tenant-alpha-document-reader", + "profile": "caring-0.4.0-rc2", + "subject_type": "Group", + "organization_relation": "Customer", + "canonical_role": "Doer", + "scope": { + "level": "Resource", + "id": "document:internal-note", + "tenant": "tenant:alpha", + "resource": "document:internal-note" + }, + "planes": ["Data"], + "capabilities": ["View"], + "exposure_modes": ["Masked", "Plaintext"], + "conditions": ["Logged"], + "restrictions": ["ExportBlocked"] + } + } + ] +} diff --git a/internal/registry/store.go b/internal/registry/store.go new file mode 100644 index 0000000..6b68516 --- /dev/null +++ b/internal/registry/store.go @@ -0,0 +1,314 @@ +package registry + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/netkingdom/flex-auth/pkg/api" +) + +// Store is a deterministic local registry for standalone development and tests. +type Store struct { + systems map[string]api.ProtectedSystemManifest + resourceManifests map[string]api.ResourceManifest + resources map[string]api.Resource + subjects map[string]api.Subject + groups map[string]api.Group + teams map[string]api.Team + tenants map[string]api.Tenant + relationships map[string]api.RelationshipFact +} + +// Snapshot is the file-backed representation of a Store. +type Snapshot struct { + Systems []api.ProtectedSystemManifest `json:"systems,omitempty"` + ResourceManifests []api.ResourceManifest `json:"resource_manifests,omitempty"` + Subjects []api.Subject `json:"subjects,omitempty"` + Groups []api.Group `json:"groups,omitempty"` + Teams []api.Team `json:"teams,omitempty"` + Tenants []api.Tenant `json:"tenants,omitempty"` + Relationships []api.RelationshipFact `json:"relationships,omitempty"` +} + +// NewStore returns an empty local registry store. +func NewStore() *Store { + return &Store{ + systems: make(map[string]api.ProtectedSystemManifest), + resourceManifests: make(map[string]api.ResourceManifest), + resources: make(map[string]api.Resource), + subjects: make(map[string]api.Subject), + groups: make(map[string]api.Group), + teams: make(map[string]api.Team), + tenants: make(map[string]api.Tenant), + relationships: make(map[string]api.RelationshipFact), + } +} + +// LoadFile loads a Store from a JSON snapshot file. +func LoadFile(path string) (*Store, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read registry snapshot: %w", err) + } + + var snapshot Snapshot + if err := json.Unmarshal(data, &snapshot); err != nil { + return nil, fmt.Errorf("unmarshal registry snapshot: %w", err) + } + + store := NewStore() + if err := store.ApplySnapshot(snapshot); err != nil { + return nil, err + } + return store, nil +} + +// SaveFile writes the store as deterministic pretty-printed JSON. +func (s *Store) SaveFile(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create registry snapshot directory: %w", err) + } + + data, err := json.MarshalIndent(s.Snapshot(), "", " ") + if err != nil { + return fmt.Errorf("marshal registry snapshot: %w", err) + } + data = append(data, '\n') + + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write registry snapshot: %w", err) + } + return nil +} + +// ApplySnapshot imports every record from a snapshot into the store. +func (s *Store) ApplySnapshot(snapshot Snapshot) error { + for _, system := range snapshot.Systems { + if err := s.PutProtectedSystem(system); err != nil { + return err + } + } + for _, manifest := range snapshot.ResourceManifests { + if err := s.ImportResourceManifest(manifest); err != nil { + return err + } + } + for _, tenant := range snapshot.Tenants { + if err := s.PutTenant(tenant); err != nil { + return err + } + } + for _, subject := range snapshot.Subjects { + if err := s.PutSubject(subject); err != nil { + return err + } + } + for _, group := range snapshot.Groups { + if err := s.PutGroup(group); err != nil { + return err + } + } + for _, team := range snapshot.Teams { + if err := s.PutTeam(team); err != nil { + return err + } + } + for _, relationship := range snapshot.Relationships { + if err := s.PutRelationship(relationship); err != nil { + return err + } + } + return nil +} + +// Snapshot returns a deterministic store snapshot. +func (s *Store) Snapshot() Snapshot { + return Snapshot{ + Systems: sortedValues(s.systems), + ResourceManifests: sortedValues(s.resourceManifests), + Subjects: sortedValues(s.subjects), + Groups: sortedValues(s.groups), + Teams: sortedValues(s.teams), + Tenants: sortedValues(s.tenants), + Relationships: sortedValues(s.relationships), + } +} + +// PutProtectedSystem stores or replaces a protected system manifest. +func (s *Store) PutProtectedSystem(system api.ProtectedSystemManifest) error { + if system.ID == "" { + return fmt.Errorf("protected system id is required") + } + s.systems[system.ID] = system + return nil +} + +// ImportResourceManifest stores a resource manifest and indexes its resources. +func (s *Store) ImportResourceManifest(manifest api.ResourceManifest) error { + if manifest.ID == "" { + return fmt.Errorf("resource manifest id is required") + } + if manifest.System == "" { + return fmt.Errorf("resource manifest system is required") + } + for _, resource := range manifest.Resources { + if resource.ID == "" || resource.Type == "" { + return fmt.Errorf("resource manifest %q contains resource with missing id or type", manifest.ID) + } + s.resources[resourceKey(manifest.System, resource.ID)] = resource + } + s.resourceManifests[manifest.ID] = manifest + return nil +} + +// ImportSubjectManifest stores subjects, groups, teams, and tenants. +func (s *Store) ImportSubjectManifest(manifest api.SubjectManifest) error { + if manifest.ID == "" { + return fmt.Errorf("subject manifest id is required") + } + for _, tenant := range manifest.Tenants { + if err := s.PutTenant(tenant); err != nil { + return err + } + } + for _, subject := range manifest.Subjects { + if err := s.PutSubject(subject); err != nil { + return err + } + } + for _, group := range manifest.Groups { + if err := s.PutGroup(group); err != nil { + return err + } + } + for _, team := range manifest.Teams { + if err := s.PutTeam(team); err != nil { + return err + } + } + return nil +} + +// PutSubject stores or replaces a subject. +func (s *Store) PutSubject(subject api.Subject) error { + if subject.ID == "" { + return fmt.Errorf("subject id is required") + } + s.subjects[subject.ID] = subject + return nil +} + +// PutGroup stores or replaces a group. +func (s *Store) PutGroup(group api.Group) error { + if group.ID == "" { + return fmt.Errorf("group id is required") + } + s.groups[group.ID] = group + return nil +} + +// PutTeam stores or replaces a team. +func (s *Store) PutTeam(team api.Team) error { + if team.ID == "" { + return fmt.Errorf("team id is required") + } + s.teams[team.ID] = team + return nil +} + +// PutTenant stores or replaces a tenant. +func (s *Store) PutTenant(tenant api.Tenant) error { + if tenant.ID == "" { + return fmt.Errorf("tenant id is required") + } + s.tenants[tenant.ID] = tenant + return nil +} + +// PutRelationship stores or replaces a relationship fact. +func (s *Store) PutRelationship(relationship api.RelationshipFact) error { + if relationship.ID == "" { + return fmt.Errorf("relationship id is required") + } + if relationship.Subject == "" || relationship.Relation == "" || relationship.Object == "" { + return fmt.Errorf("relationship %q requires subject, relation, and object", relationship.ID) + } + s.relationships[relationship.ID] = relationship + return nil +} + +// Resource looks up a resource by protected system and resource id. +func (s *Store) Resource(system, id string) (api.Resource, bool) { + resource, ok := s.resources[resourceKey(system, id)] + return resource, ok +} + +// Subject looks up a subject by id. +func (s *Store) Subject(id string) (api.Subject, bool) { + subject, ok := s.subjects[id] + return subject, ok +} + +// Group looks up a group by id. +func (s *Store) Group(id string) (api.Group, bool) { + group, ok := s.groups[id] + return group, ok +} + +// Team looks up a team by id. +func (s *Store) Team(id string) (api.Team, bool) { + team, ok := s.teams[id] + return team, ok +} + +// Tenant looks up a tenant by id. +func (s *Store) Tenant(id string) (api.Tenant, bool) { + tenant, ok := s.tenants[id] + return tenant, ok +} + +// RelationshipsForSubject returns deterministic relationship facts for a subject. +func (s *Store) RelationshipsForSubject(subject string) []api.RelationshipFact { + return s.relationshipsWhere(func(fact api.RelationshipFact) bool { + return fact.Subject == subject + }) +} + +// RelationshipsForObject returns deterministic relationship facts for an object. +func (s *Store) RelationshipsForObject(object string) []api.RelationshipFact { + return s.relationshipsWhere(func(fact api.RelationshipFact) bool { + return fact.Object == object + }) +} + +func (s *Store) relationshipsWhere(match func(api.RelationshipFact) bool) []api.RelationshipFact { + var out []api.RelationshipFact + for _, fact := range s.relationships { + if match(fact) { + out = append(out, fact) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func resourceKey(system, id string) string { + return system + "\x00" + id +} + +func sortedValues[T any](items map[string]T) []T { + keys := make([]string, 0, len(items)) + for key := range items { + keys = append(keys, key) + } + sort.Strings(keys) + + out := make([]T, 0, len(keys)) + for _, key := range keys { + out = append(out, items[key]) + } + return out +} diff --git a/internal/registry/store_test.go b/internal/registry/store_test.go new file mode 100644 index 0000000..2de1cbc --- /dev/null +++ b/internal/registry/store_test.go @@ -0,0 +1,94 @@ +package registry_test + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/netkingdom/flex-auth/internal/registry" + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestStoreImportsManifests(t *testing.T) { + store := registry.NewStore() + + var subjects api.SubjectManifest + loadYAML(t, filepath.Join("..", "..", "examples", "caring", "subject_manifest.yaml"), &subjects) + if err := store.ImportSubjectManifest(subjects); err != nil { + t.Fatalf("ImportSubjectManifest: %v", err) + } + + var relationship api.RelationshipFact + loadYAML(t, filepath.Join("..", "..", "examples", "caring", "relationship_fact.yaml"), &relationship) + if err := store.PutRelationship(relationship); err != nil { + t.Fatalf("PutRelationship: %v", err) + } + + subject, ok := store.Subject("user:alice") + if !ok { + t.Fatal("subject user:alice not found") + } + if subject.Tenant != "tenant:alpha" { + t.Errorf("subject.Tenant = %q; want tenant:alpha", subject.Tenant) + } + + relations := store.RelationshipsForObject("document:internal-note") + if len(relations) != 1 || relations[0].Subject != "group:platform-architecture" { + t.Fatalf("RelationshipsForObject = %+v; want group reader relation", relations) + } +} + +func TestStoreLoadsAndSavesDeterministicSnapshot(t *testing.T) { + snapshotPath := filepath.Join("..", "..", "examples", "caring", "registry_snapshot.json") + store, err := registry.LoadFile(snapshotPath) + if err != nil { + t.Fatalf("LoadFile: %v", err) + } + + resource, ok := store.Resource("markitect-tool", "document:internal-note") + if !ok { + t.Fatal("resource document:internal-note not found") + } + if resource.TrustZone != "internal" { + t.Errorf("resource.TrustZone = %q; want internal", resource.TrustZone) + } + + outPath := filepath.Join(t.TempDir(), "snapshot.json") + if err := store.SaveFile(outPath); err != nil { + t.Fatalf("SaveFile: %v", err) + } + + reloaded, err := registry.LoadFile(outPath) + if err != nil { + t.Fatalf("reload saved snapshot: %v", err) + } + + got := mustJSON(t, reloaded.Snapshot()) + want := mustJSON(t, store.Snapshot()) + if got != want { + t.Fatalf("saved snapshot changed after reload\nwant: %s\ngot: %s", want, got) + } +} + +func TestStoreRejectsInvalidRecords(t *testing.T) { + store := registry.NewStore() + if err := store.PutSubject(api.Subject{}); err == nil { + t.Fatal("PutSubject accepted missing id") + } + if err := store.ImportResourceManifest(api.ResourceManifest{ID: "m1"}); err == nil { + t.Fatal("ImportResourceManifest accepted missing system") + } + if err := store.PutRelationship(api.RelationshipFact{ID: "r1"}); err == nil { + t.Fatal("PutRelationship accepted missing subject/relation/object") + } +} + +func mustJSON(t *testing.T, value any) string { + t.Helper() + + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal json: %v", err) + } + return string(data) +} diff --git a/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md b/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md index ab78d1c..7a19cfa 100644 --- a/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md +++ b/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md @@ -101,7 +101,7 @@ Output: docs, JSON Schema files in `schemas/`, runnable examples in ```task id: FLEX-WP-0002-T002 -status: todo +status: done priority: high state_hub_task_id: "d8045124-f0ae-495d-87b5-24fd9528ef93" ```