From e2fba9d058e1695b93c97505778b791c88b4eaf4 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:37:08 -0400 Subject: [PATCH] feat(fixture-composition): one parser and the merge rules for module fixture fragments Each composed module now states what the fixture's standard roles may do at its kinds in a fragment next to its code, and a composition states only what is its own. libraries/fixture-composition/go is the document's one parser (Decode) and its merge (Merge): every grant on a (organization, role, kind), every installation and every stand-in module has exactly one source, and a second source is refused naming both. The run (codefly-dev/cli) merges the fragments into the workspace document the local authority reads; the local authority decodes it with the same library and checks it against its fixture. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- libraries/fixture-composition/go/README.md | 24 ++ .../go/fixturecomposition.go | 299 ++++++++++++++++++ .../go/fixturecomposition_test.go | 169 ++++++++++ libraries/fixture-composition/go/go.mod | 8 + libraries/fixture-composition/go/go.sum | 6 + .../local/fixture-composition.yaml | 8 +- modules/authority/services/local/README.md | 47 ++- modules/authority/services/local/code/go.mod | 3 + .../code/pkg/business/state/composition.go | 131 +------- .../local/code/pkg/business/state/state.go | 37 +-- modules/authority/services/local/code/work.go | 11 +- .../services/local/service.codefly.yaml | 9 +- 13 files changed, 589 insertions(+), 172 deletions(-) create mode 100644 libraries/fixture-composition/go/README.md create mode 100644 libraries/fixture-composition/go/fixturecomposition.go create mode 100644 libraries/fixture-composition/go/fixturecomposition_test.go create mode 100644 libraries/fixture-composition/go/go.mod create mode 100644 libraries/fixture-composition/go/go.sum diff --git a/README.md b/README.md index c332af93..77b689f3 100644 --- a/README.md +++ b/README.md @@ -449,10 +449,11 @@ different roles in each. The host supplies its own matrix from its own fixture. The suite requires a signed carrier and authenticated delivery workload, with the paired authority document carrying the solution's roles. -The fixture names only the authority's own vocabulary. A composing workspace -adds what its modules and solutions need: grants on the fixture's roles, -installations and stand-in modules, through the versioned `fixture-composition` -document ([what a composition adds to the fixture](modules/authority/services/local/README.md#what-a-composition-adds-to-the-fixture)). +The fixture names only the authority's own vocabulary. Each module states what +the fixture's roles may do at its kinds in a fragment next to its code, a +composition states only what is its own, and the run merges the fragments into +the versioned `fixture-composition` document +([what a composition adds to the fixture](modules/authority/services/local/README.md#what-a-composition-adds-to-the-fixture)). The local authority also logs a delivery-time role × method report and generates its local developer role from concrete delivered permissions. See [local permission diagnostics](modules/authority/services/local/README.md#permission-diagnostics-during-local-development) diff --git a/libraries/fixture-composition/go/README.md b/libraries/fixture-composition/go/README.md new file mode 100644 index 00000000..1b55cbd0 --- /dev/null +++ b/libraries/fixture-composition/go/README.md @@ -0,0 +1,24 @@ +# fixture-composition + +The local authority's `fixture-composition` document (schema +`codefly.dev/authority/fixture-composition/v1`): its one parser and its merge +rules. The local authority reads the document with `Decode` and checks it +against its fixture; the run builds it with `Merge` from the fragment each +composed module ships next to its code, plus the composition's own +([what a composition adds to the fixture](../../../modules/authority/services/local/README.md#what-a-composition-adds-to-the-fixture)). + +- `Decode` reads one versioned mapping and refuses, by name, everything that + does not depend on the fixture: an empty or null document, a null section, an + unknown field, another schema, a wildcard, a kind or action outside the + grammar, a malformed resource matcher, a role or stand-in module with no + scope, an installation with no target or role. +- `Merge` reads each `Source` with `Decode` (an error names the source) and + combines them. Every statement has exactly one source: a role's grant on one + kind, one installation, one stand-in module. A second source stating one is + `ErrConflict`, naming both sources, even when they agree. Sources are merged + in the order of their names, so the result does not depend on the order they + were found in. +- `Encode` writes YAML that `Decode` reads back unchanged. + +`go test -race ./...` covers the merge of two module fragments and a +composition's own, each conflict, and each refusal. diff --git a/libraries/fixture-composition/go/fixturecomposition.go b/libraries/fixture-composition/go/fixturecomposition.go new file mode 100644 index 00000000..a8f63f81 --- /dev/null +++ b/libraries/fixture-composition/go/fixturecomposition.go @@ -0,0 +1,299 @@ +// Package fixturecomposition is the local authority's fixture-composition +// document: what the modules a run composes, and the composition itself, add +// to the local authority's fixture. It is the one parser of the document and +// the one place its merge rules live. +// +// The fixture says who exists and which organisation roles they hold, in the +// authority's own vocabulary. What those roles may do at a module is the +// module's to say: each module ships a fragment of this document next to its +// code (configurations//fixture-composition.yaml in the module), and +// a composition ships one more for what is truly its own (a seeded +// collection's id). The run merges every fragment into the document the local +// authority reads (Merge); the local authority then checks the result against +// its fixture. +package fixturecomposition + +import ( + "bytes" + "errors" + "fmt" + "io" + "regexp" + "slices" + "strings" + + resources "github.com/codefly-dev/authority/libraries/resources/go" + "gopkg.in/yaml.v3" +) + +// Name is the workspace configuration document the local authority reads. +const Name = "fixture-composition" + +// Schema versions the document. Its sections and the fixture's organisation, +// role and person ids it names are a contract with every composing +// repository: a breaking change is a new schema, never an edit. +const Schema = "codefly.dev/authority/fixture-composition/v1" + +// The wildcards a composition never grants: a wildcard on a business role +// would erase the difference between viewer, editor and administrator. +const ( + anyKind = "*" + anyAction = "*" +) + +// A kind is dot-separated lowercase segments ("ledger.entry"); an action one +// lowercase word ("read", "read_config"). A wildcard is neither. +var ( + kindGrammar = regexp.MustCompile(`^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$`) + actionGrammar = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`) +) + +// ErrConflict is two sources stating the same thing: the same role's grant on +// one kind, the same installation, or the same stand-in module. +var ErrConflict = errors.New("fixture-composition: two sources state the same grant") + +// Document is one fixture-composition document. +type Document struct { + Schema string `yaml:"schema"` + // Roles adds grants to roles the fixture defines. + Roles []Role `yaml:"roles,omitempty"` + // Installations are installations of a solution the run composes, taking + // the fixture's own path. + Installations []Installation `yaml:"installations,omitempty"` + // Modules are modules the run supervises itself, until their own delivery. + Modules []Module `yaml:"modules,omitempty"` +} + +// Role is grants added to one role the fixture defines. +type Role struct { + Organization string `yaml:"organization"` + Name string `yaml:"name"` + Scopes []Scope `yaml:"scopes"` +} + +// Key is the role as the fixture names it, organisation/name. +func (r Role) Key() string { return r.Organization + "/" + r.Name } + +// Scope is one (kind, actions) pair, optionally narrowed to named resources. +// With no resources it covers every resource of the kind. +type Scope struct { + Kind string `yaml:"kind"` + Actions []string `yaml:"actions,flow"` + Resources []ResourceMatch `yaml:"resources,omitempty"` +} + +// ResourceMatch is one resource grant: exactly one of every resource, one id, +// an id prefix or a pattern (libraries/resources). +type ResourceMatch struct { + All bool `yaml:"all,omitempty"` + ID string `yaml:"id,omitempty"` + Prefix string `yaml:"prefix,omitempty"` + Pattern string `yaml:"pattern,omitempty"` +} + +// Grant is the match as the shared library evaluates it. +func (r ResourceMatch) Grant() resources.Grant { + return resources.Grant{All: r.All, ID: r.ID, Prefix: r.Prefix, Pattern: r.Pattern} +} + +// Installation is one installation performed through the real install path +// once its target is composed: Role is the installation's own (its agent's), +// Owner installed it and is granted the installer role, and each of Teams is +// granted Role. +type Installation struct { + Organization string `yaml:"organization"` + Target string `yaml:"target"` + Role string `yaml:"role"` + Owner string `yaml:"owner"` + Teams []string `yaml:"teams,omitempty,flow"` +} + +// Module is a module the run stands in for: its name, which is its principal, +// and the kinds and actions it serves. +type Module struct { + Module string `yaml:"module"` + Kinds []Scope `yaml:"kinds"` +} + +// Decode reads exactly one versioned mapping and checks everything that does +// not depend on the fixture. An empty or null document, a section spelled +// null and an unknown field are each refused, so a mistake never reads as +// "grants nothing"; so are a wildcard, a kind or action outside the grammar, +// a malformed resource matcher, a role or stand-in module with no scope, and +// an installation with no target or role. +func Decode(data []byte) (Document, error) { + var doc Document + var node yaml.Node + if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&node); err != nil { + if errors.Is(err, io.EOF) { + return doc, errors.New("the document is empty") + } + return doc, err + } + if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode { + return doc, errors.New("the document is not a mapping") + } + mapping := node.Content[0] + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i+1].Tag == "!!null" { + return doc, fmt.Errorf("%s is null: omit it, or give a list", mapping.Content[i].Value) + } + } + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&doc); err != nil { + return doc, err + } + if doc.Schema != Schema { + return doc, fmt.Errorf("schema is %q; this authority reads %s", doc.Schema, Schema) + } + return doc, doc.validate() +} + +func (d Document) validate() error { + for _, r := range d.Roles { + key := r.Key() + switch { + case r.Organization == "" || r.Name == "": + return fmt.Errorf("a role names no organization or no name (%q)", key) + case len(r.Scopes) == 0: + return fmt.Errorf("%s names no scope", key) + } + for _, sc := range r.Scopes { + if err := CheckScope(sc.Kind, sc.Actions); err != nil { + return fmt.Errorf("%s: %w", key, err) + } + grants := make([]resources.Grant, 0, len(sc.Resources)) + for _, m := range sc.Resources { + grants = append(grants, m.Grant()) + } + if _, err := resources.Compile(grants); err != nil { + return fmt.Errorf("%s scope %s: %w", key, sc.Kind, err) + } + } + } + for _, i := range d.Installations { + if i.Target == "" || i.Role == "" { + return errors.New("an installation names no target or no role") + } + } + for _, m := range d.Modules { + if !kindGrammar.MatchString(m.Module) || len(m.Kinds) == 0 { + return fmt.Errorf("stand-in module %q names no valid module or no kind", m.Module) + } + for _, k := range m.Kinds { + if err := CheckScope(k.Kind, k.Actions); err != nil { + return fmt.Errorf("stand-in module %s: %w", m.Module, err) + } + } + } + return nil +} + +// CheckScope refuses a wildcard by name, then anything outside the grammar +// (whitespace included). A composition grants the concrete permissions its +// modules serve. +func CheckScope(kind string, actions []string) error { + if kind == anyKind { + return errors.New("a composition grants concrete kinds; the wildcard kind \"*\" is refused") + } + if !kindGrammar.MatchString(kind) { + return fmt.Errorf("kind %q is not a kind (dot-separated lowercase segments)", kind) + } + if len(actions) == 0 { + return fmt.Errorf("scope %s names no action", kind) + } + for _, action := range actions { + if action == anyAction { + return fmt.Errorf("a composition grants concrete actions; the wildcard action \"*\" on %s is refused", kind) + } + if !actionGrammar.MatchString(action) { + return fmt.Errorf("action %q on %s is not an action (one lowercase word)", action, kind) + } + } + return nil +} + +// Source is one fragment and where it came from, in the words a person fixes +// it by: "module documents (modules/documents/configurations/local/...)". +type Source struct { + Name string + Data []byte +} + +// Merge combines fragments into the one document the local authority reads. +// +// Every statement has exactly one source: a role's grant on one kind (the +// organisation, the role and the kind together), one installation (its +// organisation and target), one stand-in module. Two sources stating the same +// one is refused naming both, even when they agree, because the fragment that +// owns it is the one a reader goes to: a composition narrowing a module's grant +// to a seeded resource must not be silently widened by the module's, and a +// module's grant copied into a composition is the drift this exists to end. +// Within one source the document reads as it does alone. +// +// The result does not depend on the order the sources are given in: they are +// merged in the order of their names, and two sources of one name are refused. +func Merge(sources []Source) (Document, error) { + sorted := slices.Clone(sources) + slices.SortFunc(sorted, func(a, b Source) int { return strings.Compare(a.Name, b.Name) }) + merged := Document{Schema: Schema} + grants := map[string]string{} + installations := map[string]string{} + modules := map[string]string{} + for i, source := range sorted { + if source.Name == "" { + return Document{}, errors.New("a fixture-composition fragment has no source name") + } + if i > 0 && sorted[i-1].Name == source.Name { + return Document{}, fmt.Errorf("two fixture-composition fragments are both named %s", source.Name) + } + doc, err := Decode(source.Data) + if err != nil { + return Document{}, fmt.Errorf("%s: %w", source.Name, err) + } + for _, r := range doc.Roles { + for _, sc := range r.Scopes { + key := r.Key() + " " + sc.Kind + if owner, ok := grants[key]; ok && owner != source.Name { + return Document{}, fmt.Errorf("%w: %s's grant on %s is stated by %s and by %s; state it in one of them", + ErrConflict, r.Key(), sc.Kind, owner, source.Name) + } + grants[key] = source.Name + } + } + for _, in := range doc.Installations { + key := in.Organization + "/" + in.Target + if owner, ok := installations[key]; ok && owner != source.Name { + return Document{}, fmt.Errorf("%w: the installation of %s at %s is stated by %s and by %s; state it in one of them", + ErrConflict, in.Target, in.Organization, owner, source.Name) + } + installations[key] = source.Name + } + for _, m := range doc.Modules { + if owner, ok := modules[m.Module]; ok && owner != source.Name { + return Document{}, fmt.Errorf("%w: stand-in module %s is stated by %s and by %s; state it in one of them", + ErrConflict, m.Module, owner, source.Name) + } + modules[m.Module] = source.Name + } + merged.Roles = append(merged.Roles, doc.Roles...) + merged.Installations = append(merged.Installations, doc.Installations...) + merged.Modules = append(merged.Modules, doc.Modules...) + } + return merged, nil +} + +// Encode writes the document as YAML that Decode reads back unchanged. +func Encode(doc Document) ([]byte, error) { + var out bytes.Buffer + encoder := yaml.NewEncoder(&out) + encoder.SetIndent(2) + if err := encoder.Encode(doc); err != nil { + return nil, err + } + if err := encoder.Close(); err != nil { + return nil, err + } + return out.Bytes(), nil +} diff --git a/libraries/fixture-composition/go/fixturecomposition_test.go b/libraries/fixture-composition/go/fixturecomposition_test.go new file mode 100644 index 00000000..42798e5a --- /dev/null +++ b/libraries/fixture-composition/go/fixturecomposition_test.go @@ -0,0 +1,169 @@ +package fixturecomposition_test + +import ( + "errors" + "reflect" + "strings" + "testing" + + fixturecomposition "github.com/codefly-dev/authority/libraries/fixture-composition/go" +) + +const head = "schema: codefly.dev/authority/fixture-composition/v1\n" + +// Two modules' fragments and the composition's own merge into one document +// that holds each source's grants, in an order that does not depend on the +// order the sources were given in, and that reads back unchanged. +func TestMergeTwoModuleFragmentsAndTheCompositionsOwn(t *testing.T) { + store := fixturecomposition.Source{Name: "module documents", Data: []byte(head + ` +roles: + - organization: acme + name: viewer + scopes: + - {kind: documents, actions: [read]} + - organization: acme + name: admin + scopes: + - {kind: documents, actions: [read, write, ingest]} + - {kind: documents.collection, actions: [read, transfer, admin, publish, freeze]} +`)} + chat := fixturecomposition.Source{Name: "module robin", Data: []byte(head + ` +roles: + - organization: acme + name: viewer + scopes: + - {kind: robin.conversations, actions: [read]} +`)} + own := fixturecomposition.Source{Name: "module robin-playground", Data: []byte(head + ` +roles: + - organization: acme + name: viewer + scopes: + - kind: documents.collection + actions: [read] + resources: [{id: "derived\x1frobin-playground"}] +installations: + - {organization: acme, target: atlas, role: viewer, owner: alex, teams: [finance]} +`)} + empty := fixturecomposition.Source{Name: "module authority", Data: []byte(head)} + + merged, err := fixturecomposition.Merge([]fixturecomposition.Source{own, chat, store, empty}) + if err != nil { + t.Fatal(err) + } + reordered, err := fixturecomposition.Merge([]fixturecomposition.Source{empty, store, chat, own}) + if err != nil || !reflect.DeepEqual(merged, reordered) { + t.Fatalf("the merge depends on the order of its sources: %v", err) + } + var viewer []string + for _, r := range merged.Roles { + if r.Key() == "acme/viewer" { + for _, sc := range r.Scopes { + viewer = append(viewer, sc.Kind) + } + } + } + if want := []string{"documents", "robin.conversations", "documents.collection"}; !reflect.DeepEqual(viewer, want) { + t.Fatalf("the viewer holds %v, want %v", viewer, want) + } + if len(merged.Installations) != 1 || merged.Installations[0].Target != "atlas" { + t.Fatalf("installations: %+v", merged.Installations) + } + + encoded, err := fixturecomposition.Encode(merged) + if err != nil { + t.Fatal(err) + } + back, err := fixturecomposition.Decode(encoded) + if err != nil || !reflect.DeepEqual(back, merged) { + t.Fatalf("the encoded merge does not read back: %v\n%s", err, encoded) + } + if !strings.Contains(strings.ToLower(string(encoded)), `"derived\x1frobin-playground"`) { + t.Fatalf("the seeded id lost its separator:\n%s", encoded) + } +} + +// A grant one source owns that another restates is refused naming both, even +// when the two agree; so are an installation and a stand-in module stated +// twice, and two sources of one name. +func TestMergeRefusesAConflictNamingBothSources(t *testing.T) { + module := fixturecomposition.Source{Name: "module documents", Data: []byte(head + ` +roles: [{organization: acme, name: viewer, scopes: [{kind: documents.collection, actions: [read]}]}] +installations: [{organization: acme, target: atlas, role: viewer, owner: alex}] +modules: [{module: oracle, kinds: [{kind: oracle.answers, actions: [read]}]}] +`)} + for name, tc := range map[string]struct { + other string + reason string + }{ + "narrowed grant": {`roles: [{organization: acme, name: viewer, scopes: [{kind: documents.collection, actions: [read], resources: [{id: seeded}]}]}]`, + "acme/viewer's grant on documents.collection is stated by module documents and by module playground"}, + "agreeing grant": {`roles: [{organization: acme, name: viewer, scopes: [{kind: documents.collection, actions: [read]}]}]`, + "acme/viewer's grant on documents.collection"}, + "installation": {`installations: [{organization: acme, target: atlas, role: editor, owner: alex}]`, + "the installation of atlas at acme is stated by module documents and by module playground"}, + "stand-in": {`modules: [{module: oracle, kinds: [{kind: oracle.answers, actions: [write]}]}]`, + "stand-in module oracle is stated by module documents and by module playground"}, + } { + t.Run(name, func(t *testing.T) { + other := fixturecomposition.Source{Name: "module playground", Data: []byte(head + tc.other + "\n")} + _, err := fixturecomposition.Merge([]fixturecomposition.Source{other, module}) + if !errors.Is(err, fixturecomposition.ErrConflict) || !strings.Contains(err.Error(), tc.reason) { + t.Fatalf("got %v, want a conflict naming %q", err, tc.reason) + } + }) + } + // Another role, another organisation or another kind is not a conflict. + for _, other := range []string{ + `roles: [{organization: acme, name: editor, scopes: [{kind: documents.collection, actions: [read]}]}]`, + `roles: [{organization: globex, name: viewer, scopes: [{kind: documents.collection, actions: [read]}]}]`, + `roles: [{organization: acme, name: viewer, scopes: [{kind: documents, actions: [read]}]}]`, + } { + if _, err := fixturecomposition.Merge([]fixturecomposition.Source{module, {Name: "module playground", Data: []byte(head + other + "\n")}}); err != nil { + t.Errorf("%s: %v", other, err) + } + } + if _, err := fixturecomposition.Merge([]fixturecomposition.Source{module, module}); err == nil || !strings.Contains(err.Error(), "both named module documents") { + t.Fatalf("two sources of one name: %v", err) + } +} + +// A fragment that does not read is refused naming its source, and the +// document's own rules hold in a fragment as they do in the merged result. +func TestMergeNamesTheFragmentThatDoesNotRead(t *testing.T) { + for name, tc := range map[string]struct{ doc, reason string }{ + "empty": {"", "the document is empty"}, + "null": {"null", "not a mapping"}, + "other schema": {"schema: codefly.dev/authority/fixture-composition/v2\n", "this authority reads"}, + "null roles": {head + "roles: null\n", "roles is null"}, + "misspelt": {head + "grants: []\n", "field grants not found"}, + "generated": {head + `roles: [{organization: acme, name: developer, generated: true, scopes: [{kind: ledger.entry, actions: [read]}]}]`, "field generated not found"}, + "no scope": {head + `roles: [{organization: acme, name: editor}]`, "acme/editor names no scope"}, + "wildcard kind": {head + `roles: [{organization: acme, name: viewer, scopes: [{kind: "*", actions: [read]}]}]`, "wildcard kind"}, + "wildcard action": {head + `roles: [{organization: acme, name: viewer, scopes: [{kind: ledger.entry, actions: ["*"]}]}]`, "wildcard action"}, + "padded action": {head + `roles: [{organization: acme, name: editor, scopes: [{kind: ledger.entry, actions: [" read"]}]}]`, "is not an action"}, + "bad resource": {head + `roles: [{organization: acme, name: editor, scopes: [{kind: ledger.entry, actions: [read], resources: [{}]}]}]`, "acme/editor scope ledger.entry"}, + "no target": {head + `installations: [{organization: acme, role: viewer, owner: alex}]`, "no target or no role"}, + "bad stand-in": {head + `modules: [{module: Oracle, kinds: [{kind: oracle.answers, actions: [read]}]}]`, "names no valid module"}, + } { + t.Run(name, func(t *testing.T) { + _, err := fixturecomposition.Merge([]fixturecomposition.Source{{Name: "module ledger", Data: []byte(tc.doc)}}) + if err == nil || !strings.Contains(err.Error(), tc.reason) || !strings.HasPrefix(err.Error(), "module ledger: ") { + t.Fatalf("got %v, want a refusal of module ledger naming %q", err, tc.reason) + } + }) + } +} + +// No fragment merges to the schema line alone, which the local authority +// reads as a composition that adds nothing. +func TestMergeOfNothingIsTheSchemaAlone(t *testing.T) { + merged, err := fixturecomposition.Merge(nil) + if err != nil { + t.Fatal(err) + } + encoded, err := fixturecomposition.Encode(merged) + if err != nil || string(encoded) != head { + t.Fatalf("%q %v", encoded, err) + } +} diff --git a/libraries/fixture-composition/go/go.mod b/libraries/fixture-composition/go/go.mod new file mode 100644 index 00000000..da00a70b --- /dev/null +++ b/libraries/fixture-composition/go/go.mod @@ -0,0 +1,8 @@ +module github.com/codefly-dev/authority/libraries/fixture-composition/go + +go 1.26.0 + +require ( + github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/libraries/fixture-composition/go/go.sum b/libraries/fixture-composition/go/go.sum new file mode 100644 index 00000000..54aee91f --- /dev/null +++ b/libraries/fixture-composition/go/go.sum @@ -0,0 +1,6 @@ +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93 h1:4EygH7/J0E6aM7hUPdbQGfit1rsLUNCtWJ8kMh2b9PE= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261026023425-000d44495d93/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/modules/authority/configurations/local/fixture-composition.yaml b/modules/authority/configurations/local/fixture-composition.yaml index d154e0ff..38c70b25 100644 --- a/modules/authority/configurations/local/fixture-composition.yaml +++ b/modules/authority/configurations/local/fixture-composition.yaml @@ -1,6 +1,6 @@ -# The default of the local authority's fixture-composition document: a -# composition that composes the authority and supplies no document of its own -# adds nothing to the fixture. A composing workspace's -# configurations/local/fixture-composition.yaml replaces this one whole +# The authority module's fragment of the local authority's fixture-composition +# document: the authority serves no kind beyond the fixture's own vocabulary, so +# it adds nothing. Every composed module ships its own fragment next to its +# code, and the run merges them into the workspace's document # (services/local/README.md, "What a composition adds to the fixture"). schema: codefly.dev/authority/fixture-composition/v1 diff --git a/modules/authority/services/local/README.md b/modules/authority/services/local/README.md index be549f21..dec7e174 100644 --- a/modules/authority/services/local/README.md +++ b/modules/authority/services/local/README.md @@ -87,9 +87,16 @@ declare the group: declaring it would give it authority bindings of its own. The embedded fixture (`code/fixtures/local.yaml`) says who exists and which organisation roles they hold, in the authority's own vocabulary only (`console.identity`). It names no module's kinds, no solution and no stand-in -module. Each of those belongs to the workspace that composes it, which adds it -through the workspace configuration document `fixture-composition` -(`configurations/local/fixture-composition.yaml` in that workspace): +module. What the fixture's roles may do at a module is that module's to say, +once, next to its code; what is truly a composition's own (a collection its +seed writes, an installation of its solution) is the composition's. Both are +**fragments** of the workspace configuration document `fixture-composition`: + +- a module ships `configurations//fixture-composition.yaml` in its own + directory: the grants of the fixture's standard roles (viewer, editor, admin, + …) at that module's kinds, and nothing about any other module; +- a composition ships the same file in its own module's directory, for what + only it knows: the seeded collection a viewer reads, by id. ```yaml schema: codefly.dev/authority/fixture-composition/v1 @@ -107,18 +114,37 @@ modules: # a module the run supervises itself, until its own delivery kinds: [{kind: oracle.answers, actions: [read]}] ``` -The authority module ships an empty default -(`modules/authority/configurations/local/fixture-composition.yaml`, the schema -line alone). A composing workspace's own document replaces it whole, so a -composition that supplies none boots on the fixture alone. +**The run merges them.** Before the local authority boots, `codefly run` +merges the fragment of every module the workspace composes into the +workspace's own `configurations//fixture-composition.yaml`, which the +local authority reads (`codefly generate fixture-composition --env local` does +the same on its own). That file is generated: it says so in its first line and +is never committed. A hand-written one is never overwritten: `codefly generate` +refuses, and `codefly run` reads it as it stands and says that no module's +fragment is applied until it is deleted. The merge +rules are this repository's, in [`libraries/fixture-composition`](../../../../libraries/fixture-composition/go/README.md), +the one parser of the document: + +- every statement has exactly one source: a role's grant on one kind, one + installation, one stand-in module. Two fragments stating the same one are + refused naming both, even when they agree, so a composition narrowing a + module's grant to its seeded collection is never silently widened by the + module's, and a module's grant copied into a playground is refused rather + than left to drift; +- the result does not depend on the order fragments are found in. + +The authority module's own fragment +(`modules/authority/configurations/local/fixture-composition.yaml`) is the +schema line alone, so a composition of the authority and nothing else boots +on the fixture alone. **The contract.** The `schema` line versions the document. Its sections, and the fixture's organisation, role and person ids it names (`acme`, `globex`, `viewer`, `editor`, `admin`, `alex`, …), are a contract with every composing repository. A breaking change is a new schema version, never an edit to v1. -**What is refused.** Each of these refuses the boot by name and changes no -role: +**What is refused.** Each of these refuses the merge or the boot by name and +changes no role: - an empty or null document, a section spelled `null`, an unknown field, or a schema other than v1; - an entry naming a role the fixture does not define, or the generated @@ -129,7 +155,8 @@ role: distinct; - a kind or action outside the grammar, surrounding whitespace included; - a malformed resource matcher; -- an installation whose organisation or owner the fixture does not define. +- an installation whose organisation or owner the fixture does not define; +- two fragments stating the same grant, installation or stand-in module. The boot log names each effect, for example `the composition grants acme/editor: ledger.entry:[read write]`, or says that the composition adds diff --git a/modules/authority/services/local/code/go.mod b/modules/authority/services/local/code/go.mod index 7941ee79..50cd9392 100644 --- a/modules/authority/services/local/code/go.mod +++ b/modules/authority/services/local/code/go.mod @@ -11,6 +11,7 @@ require ( github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0 github.com/codefly-dev/authority/libraries/conformance/go v0.0.0 github.com/codefly-dev/authority/libraries/delivery/go v0.0.0 + github.com/codefly-dev/authority/libraries/fixture-composition/go v0.0.0 github.com/codefly-dev/authority/libraries/hop/go v0.0.0 github.com/codefly-dev/authority/libraries/resources/go v0.0.0 github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 @@ -94,3 +95,5 @@ replace github.com/codefly-dev/authority/libraries/resources/go => ../../../../. replace github.com/codefly-dev/authority/libraries/hop/go => ../../../../../libraries/hop/go replace github.com/codefly-dev/authority/libraries/authority-core/go => ../../../../../libraries/authority-core/go + +replace github.com/codefly-dev/authority/libraries/fixture-composition/go => ../../../../../libraries/fixture-composition/go diff --git a/modules/authority/services/local/code/pkg/business/state/composition.go b/modules/authority/services/local/code/pkg/business/state/composition.go index e37f6df6..40be0d27 100644 --- a/modules/authority/services/local/code/pkg/business/state/composition.go +++ b/modules/authority/services/local/code/pkg/business/state/composition.go @@ -1,57 +1,40 @@ package state import ( - "bytes" "context" - "errors" "fmt" - "io" - "regexp" "slices" - resources "github.com/codefly-dev/authority/libraries/resources/go" + fixturecomposition "github.com/codefly-dev/authority/libraries/fixture-composition/go" "gopkg.in/yaml.v3" ) // CompositionDocument is the workspace configuration document through which -// a composition adds its own part to the fixture: what the fixture's +// a composition adds its part to the fixture: what the fixture's // organisation roles hold at the modules it composes, the installations of // the solutions it composes, and the modules it supervises itself. The // fixture says who exists and which roles they hold, in the authority's own -// vocabulary; every module's and solution's vocabulary belongs to the -// composition that composes it, never to this repository. The authority -// module ships an empty default, so a composition that supplies none boots -// on the fixture alone. -const CompositionDocument = "fixture-composition" +// vocabulary; each module states its own grants in a fragment next to its +// code, the composition states only what is its own, and the run merges the +// fragments into this document (libraries/fixture-composition). +const CompositionDocument = fixturecomposition.Name // CompositionSchema versions the document. Its sections and the fixture's // organisation, role and person ids it names are a contract with every // composing repository: a breaking change is a new schema, never an edit. -const CompositionSchema = "codefly.dev/authority/fixture-composition/v1" - -// A kind is dot-separated lowercase segments ("ledger.entry"); an action one -// lowercase word ("read", "read_config"). A wildcard is neither. -var ( - kindGrammar = regexp.MustCompile(`^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)*$`) - actionGrammar = regexp.MustCompile(`^[a-z][a-z0-9_-]*$`) -) - -type compositionFile struct { - Schema string `yaml:"schema"` - Roles []Role `yaml:"roles"` - Installations []FixtureInstallation `yaml:"installations"` - Modules []FixtureModule `yaml:"modules"` -} +const CompositionSchema = fixturecomposition.Schema // ApplyComposition adds a composition's document to the loaded fixture. Roles // entries add scopes to roles the fixture defines (a composition grants; it // never creates a role or a person, and never touches the generated developer // role). Installations and modules take the fixture's own path: an // installation is performed when its target is composed, a module stands in -// until its own delivery replaces it. Nothing is applied unless the whole +// until its own delivery replaces it. The document is read by the library's +// one parser, which holds every rule that does not depend on the fixture; +// this checks it against the fixture. Nothing is applied unless the whole // document is valid. It returns one line per effect, for the boot log. func (s *State) ApplyComposition(ctx context.Context, data []byte) ([]string, error) { - doc, err := decodeComposition(data) + doc, err := fixturecomposition.Decode(data) if err != nil { return nil, fmt.Errorf("%s: %w", CompositionDocument, err) } @@ -81,46 +64,14 @@ func (s *State) ApplyComposition(ctx context.Context, data []byte) ([]string, er return append(lines, more...), nil } -// decodeComposition reads exactly one versioned mapping. An empty or null -// document, a section spelled null and an unknown field are each refused, so -// a mistake never reads as "grants nothing". -func decodeComposition(data []byte) (compositionFile, error) { - var doc compositionFile - var node yaml.Node - if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&node); err != nil { - if errors.Is(err, io.EOF) { - return doc, errors.New("the document is empty") - } - return doc, err - } - if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode { - return doc, errors.New("the document is not a mapping") - } - mapping := node.Content[0] - for i := 0; i+1 < len(mapping.Content); i += 2 { - if mapping.Content[i+1].Tag == "!!null" { - return doc, fmt.Errorf("%s is null: omit it, or give a list", mapping.Content[i].Value) - } - } - decoder := yaml.NewDecoder(bytes.NewReader(data)) - decoder.KnownFields(true) - if err := decoder.Decode(&doc); err != nil { - return doc, err - } - if doc.Schema != CompositionSchema { - return doc, fmt.Errorf("schema is %q; this authority reads %s", doc.Schema, CompositionSchema) - } - return doc, nil -} - // composedRoles is the fixture's roles with the document's grants added. -func (s *State) composedRoles(doc compositionFile) (map[string]Role, []string, error) { +func (s *State) composedRoles(doc fixturecomposition.Document) (map[string]Role, []string, error) { s.mu.RLock() defer s.mu.RUnlock() granted := map[string]Role{} var lines []string for _, r := range doc.Roles { - key := r.Organization + "/" + r.Name + key := r.Key() role, ok := granted[key] if !ok { role, ok = s.roles[key] @@ -129,22 +80,10 @@ func (s *State) composedRoles(doc compositionFile) (map[string]Role, []string, e switch { case !ok: return nil, nil, fmt.Errorf("%s is not a role the fixture defines", key) - case r.Generated || role.Generated: + case role.Generated: return nil, nil, fmt.Errorf("%s is derived from the delivered catalog and takes no grant", key) - case len(r.Scopes) == 0: - return nil, nil, fmt.Errorf("%s names no scope", key) } for _, sc := range r.Scopes { - if err := checkScope(sc.Kind, sc.Actions); err != nil { - return nil, nil, fmt.Errorf("%s: %w", key, err) - } - grants := make([]resources.Grant, 0, len(sc.Resources)) - for _, m := range sc.Resources { - grants = append(grants, m.Grant()) - } - if _, err := resources.Compile(grants); err != nil { - return nil, nil, fmt.Errorf("%s scope %s: %w", key, sc.Kind, err) - } role.Scopes = append(role.Scopes, sc) lines = append(lines, fmt.Sprintf("grants %s: %s:%v", key, sc.Kind, sc.Actions)) } @@ -153,35 +92,9 @@ func (s *State) composedRoles(doc compositionFile) (map[string]Role, []string, e return granted, lines, nil } -// checkScope refuses a wildcard by name, then anything outside the grammar -// (whitespace included). A composition grants the concrete permissions its -// modules serve: a wildcard on a business role would erase the difference -// between viewer, editor and administrator, which the generated developer -// role never carries either. -func checkScope(kind string, actions []string) error { - if kind == AnyKind { - return errors.New("a composition grants concrete kinds; the wildcard kind \"*\" is refused") - } - if !kindGrammar.MatchString(kind) { - return fmt.Errorf("kind %q is not a kind (dot-separated lowercase segments)", kind) - } - if len(actions) == 0 { - return fmt.Errorf("scope %s names no action", kind) - } - for _, action := range actions { - if action == AnyAction { - return fmt.Errorf("a composition grants concrete actions; the wildcard action \"*\" on %s is refused", kind) - } - if !actionGrammar.MatchString(action) { - return fmt.Errorf("action %q on %s is not an action (one lowercase word)", action, kind) - } - } - return nil -} - -// checkComposedSetup checks the document's installations and stand-in modules -// against the fixture before anything is applied. -func (s *State) checkComposedSetup(doc compositionFile) ([]string, error) { +// checkComposedSetup checks the document's installations against the fixture +// before anything is applied, and names each stand-in module. +func (s *State) checkComposedSetup(doc fixturecomposition.Document) ([]string, error) { s.mu.RLock() defer s.mu.RUnlock() var lines []string @@ -189,8 +102,6 @@ func (s *State) checkComposedSetup(doc compositionFile) ([]string, error) { owner, ok := s.people[i.Owner] _, known := s.orgs[i.Organization] switch { - case i.Target == "" || i.Role == "": - return nil, fmt.Errorf("an installation names no target or no role") case !known: return nil, fmt.Errorf("installation of %s: %q is not an organisation the fixture defines", i.Target, i.Organization) case !ok || owner.Organization != i.Organization: @@ -199,14 +110,6 @@ func (s *State) checkComposedSetup(doc compositionFile) ([]string, error) { lines = append(lines, fmt.Sprintf("installs %s at %s as %s, granting %s the installer role and %v the role %s", i.Target, i.Organization, i.Role, i.Owner, i.Teams, i.Role)) } for _, m := range doc.Modules { - if !kindGrammar.MatchString(m.Module) || len(m.Kinds) == 0 { - return nil, fmt.Errorf("stand-in module %q names no valid module or no kind", m.Module) - } - for _, k := range m.Kinds { - if err := checkScope(k.Kind, k.Actions); err != nil { - return nil, fmt.Errorf("stand-in module %s: %w", m.Module, err) - } - } lines = append(lines, fmt.Sprintf("stands in for module %s until its delivery", m.Module)) } return lines, nil diff --git a/modules/authority/services/local/code/pkg/business/state/state.go b/modules/authority/services/local/code/pkg/business/state/state.go index be917e81..1d63f4ff 100644 --- a/modules/authority/services/local/code/pkg/business/state/state.go +++ b/modules/authority/services/local/code/pkg/business/state/state.go @@ -7,6 +7,7 @@ package state import ( + fixturecomposition "github.com/codefly-dev/authority/libraries/fixture-composition/go" "context" "crypto/ed25519" "errors" @@ -37,27 +38,14 @@ var ( ) // Scope is one (kind, actions) pair as the fixture spells it, optionally -// narrowed to named resources. With no resources it covers every resource of -// the kind. -type Scope struct { - Kind string `yaml:"kind"` - Actions []string `yaml:"actions"` - Resources []ResourceMatch `yaml:"resources"` -} +// narrowed to named resources (ResourceMatch). With no resources it covers +// every resource of the kind. The fixture and the fixture-composition document +// spell it the same way, so it is the document's own type. +type Scope = fixturecomposition.Scope // ResourceMatch is one resource grant as the fixture spells it: exactly one of // every resource, one id, an id prefix or a pattern (libraries/resources). -type ResourceMatch struct { - All bool `yaml:"all"` - ID string `yaml:"id"` - Prefix string `yaml:"prefix"` - Pattern string `yaml:"pattern"` -} - -// Grant is the match as the shared library evaluates it. -func (r ResourceMatch) Grant() resources.Grant { - return resources.Grant{All: r.All, ID: r.ID, Prefix: r.Prefix, Pattern: r.Pattern} -} +type ResourceMatch = fixturecomposition.ResourceMatch // Person is a fixture person. type Person struct { @@ -180,22 +168,13 @@ type fixtureFile struct { // FixtureModule is one module the fixture composes: its name, which is its // principal, and the kinds and actions it serves. -type FixtureModule struct { - Module string `yaml:"module"` - Kinds []Scope `yaml:"kinds"` -} +type FixtureModule = fixturecomposition.Module // FixtureInstallation is one installation the fixture performs through the // real install path, once its target is composed: Role is the installation's // own (its agent's), Owner installed it and is granted the installer role, // and each of Teams is granted Role. -type FixtureInstallation struct { - Organization string `yaml:"organization"` - Target string `yaml:"target"` - Role string `yaml:"role"` - Owner string `yaml:"owner"` - Teams []string `yaml:"teams"` -} +type FixtureInstallation = fixturecomposition.Installation // State is the host; every method is safe for concurrent use. type State struct { diff --git a/modules/authority/services/local/code/work.go b/modules/authority/services/local/code/work.go index ccdf091b..eade8fef 100644 --- a/modules/authority/services/local/code/work.go +++ b/modules/authority/services/local/code/work.go @@ -128,16 +128,15 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl } // applyComposition adds the composition's part of the fixture: the workspace -// configuration document fixture-composition, which a composing workspace -// supplies for the modules and solutions it composes. The authority module -// ships an empty default (modules/authority/configurations/local), which a -// composition's own document replaces whole. A document that does not read, or -// that the fixture refuses, refuses to boot. +// configuration document fixture-composition, which the run merges from the +// fragment every composed module ships next to its code and the composition's +// own (libraries/fixture-composition). A document that does not read, or that +// the fixture refuses, refuses to boot. func applyComposition(ctx context.Context, st *state.State) error { w := wool.Get(ctx).In("local") document, err := codefly.For(ctx).WorkspaceConfigurationDocument(state.CompositionDocument) if errors.Is(err, codefly.ErrConfigurationDocumentMissing) { - return fmt.Errorf("no %s document was delivered; the authority module ships a default, so this run did not compose it", state.CompositionDocument) + return fmt.Errorf("no %s document was delivered; the run merges one from the composed modules' fragments (codefly generate %s), so this run did not compose it", state.CompositionDocument, state.CompositionDocument) } if err != nil { return err diff --git a/modules/authority/services/local/service.codefly.yaml b/modules/authority/services/local/service.codefly.yaml index c4899c82..a6ba55ae 100644 --- a/modules/authority/services/local/service.codefly.yaml +++ b/modules/authority/services/local/service.codefly.yaml @@ -11,11 +11,10 @@ agent: # A run that composes no store delivers none, and the local authority keeps approvals in # memory, saying so at boot (code/work.go approvalsFor). # federation: the host coordinate a signed delivery is checked against. -# fixture-composition: what the composing workspace adds to the fixture (grants -# on its roles at the modules it composes, installations of its solutions, -# stand-in modules); this module ships an empty default under -# configurations/local, which a composition's own document replaces whole -# (code/work.go applyComposition). +# fixture-composition: what the composition adds to the fixture (grants on its +# roles at the modules it composes, installations of its solutions, stand-in +# modules), merged by the run from every composed module's fragment +# (libraries/fixture-composition; code/work.go applyComposition). # Keys publishes the hop's admission key once the hop registers it over its # workload credential (RegisterAdmissionKey): nothing about the hop is # configured here, its seed never leaves it, and a run with no hop publishes none.