generated from coulomb/repo-seed
Implement local registry store
This commit is contained in:
314
internal/registry/store.go
Normal file
314
internal/registry/store.go
Normal file
@@ -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
|
||||
}
|
||||
94
internal/registry/store_test.go
Normal file
94
internal/registry/store_test.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user