Implement local registry store
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled

This commit is contained in:
2026-05-17 05:10:17 +02:00
parent 4f4c290684
commit 3c4f8fc2b4
4 changed files with 508 additions and 1 deletions

View File

@@ -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"]
}
}
]
}

314
internal/registry/store.go Normal file
View 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
}

View 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)
}

View File

@@ -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"
```