From 69d55c8e409b5e0a205316932ea09c21d6ea6626 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:35:13 -0400 Subject: [PATCH 1/2] fix: load composition grants for local fixture roles Reuse Codefly workspace documents and the existing fixture grant resolver so compositions own their module permissions. Refuse malformed grants atomically before listeners start. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 + modules/authority/services/local/README.md | 36 +++++ .../local/code/composition_roles_test.go | 70 +++++++++ .../pkg/business/state/composition_roles.go | 90 ++++++++++++ .../business/state/composition_roles_test.go | 133 ++++++++++++++++++ modules/authority/services/local/code/work.go | 28 ++++ .../services/local/service.codefly.yaml | 5 + 7 files changed, 366 insertions(+) create mode 100644 modules/authority/services/local/code/composition_roles_test.go create mode 100644 modules/authority/services/local/code/pkg/business/state/composition_roles.go create mode 100644 modules/authority/services/local/code/pkg/business/state/composition_roles_test.go diff --git a/README.md b/README.md index 27dbf364..74d898aa 100644 --- a/README.md +++ b/README.md @@ -433,6 +433,10 @@ own fixture. When the fixture signs, the suite composes its solution as a carrie with the authority's document carrying its roles; the sketch remains for a fixture that does not sign. +A composition grants the fixture's organisation roles what its modules serve +through the `fixture-roles` workspace configuration document +([grants a composition supplies](modules/authority/services/local/README.md#grants-a-composition-supplies)), +so a module's permissions need not be written into this repository's 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/modules/authority/services/local/README.md b/modules/authority/services/local/README.md index 72b1444a..82224b56 100644 --- a/modules/authority/services/local/README.md +++ b/modules/authority/services/local/README.md @@ -37,6 +37,42 @@ Generated code (`pkg/gen`, `*_gen.go`, `main.go`) comes from `codefly generate` and the agent; `work.go` and everything under `pkg/business`, `pkg/infra` and `plugins` is written here. +## Grants a composition supplies + +The fixture says who exists, which organisation roles they hold and what each +role grants of the platform's own vocabulary. What a role may do at one module +belongs to that module's composition, not to this repository: a module's +playground supplies the workspace configuration document `fixture-roles` +(`configurations/local/fixture-roles.yaml` in its workspace), spelled as the +fixture's `roles:` entries, and the local authority adds each entry's scopes to +the fixture role it names before any listener starts: + +```yaml +roles: + - organization: acme + name: editor + scopes: + - {kind: ledger.entry, actions: [write]} +``` + +A composition grants; it never creates a role or a person. An entry naming a +role the fixture does not define, the generated `developer` role, no scope, a +scope with no kind or action, an unknown field or a malformed resource matcher +refuses the boot by name, and a refused document changes no role. Empty or null +documents and additional YAML documents are refused; `roles: []` explicitly +grants nothing. The boot log +names each grant (`the composition grants acme/editor: ledger.entry:[write]`), +or says the composition supplied none. The delivery-time report below then +reads the granted roles like any other. + +This reuses Codefly's public workspace document delivery and SDK +`WorkspaceConfigurationDocument`, the fixture's `Role`/`Scope` schema, the +shared resource matcher compiler, and the existing person-scope resolver. +The delivered method catalog generates only `developer` today +(`pkg/business/state/permissions.go`); it carries no organisation-role-class +mapping for editor or admin. Composition grants fill that local fixture gap +without introducing another resolver or changing the host authority interface. + ## Permission diagnostics during local development The local fixture's `developer` role uses `generated: true` and declares no diff --git a/modules/authority/services/local/code/composition_roles_test.go b/modules/authority/services/local/code/composition_roles_test.go new file mode 100644 index 00000000..316f7697 --- /dev/null +++ b/modules/authority/services/local/code/composition_roles_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "gateway/pkg/business/testkit" + + "github.com/codefly-dev/core/configurations" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + codefly "github.com/codefly-dev/sdk-go" +) + +// Exercise the production boot hook through Codefly's file loader and SDK +// document carrier, rather than passing fixture bytes straight to state. +func TestCompositionRolesReachBootThroughCodefly(t *testing.T) { + ctx := context.Background() + t.Setenv(resources.EnvironmentPrefix, "local") + t.Cleanup(func() { _ = codefly.InjectConfigurations() }) + for _, tc := range []struct { + name, document, reason string + granted bool + }{ + {name: "absent"}, + {name: "composition", document: "roles:\n - organization: acme\n name: editor\n scopes:\n - {kind: ledger.entry, actions: [write]}\n", granted: true}, + {name: "unknown role", document: "roles: [{organization: acme, name: missing, scopes: [{kind: ledger.entry, actions: [write]}]}]", reason: "acme/missing is not a role"}, + {name: "null", document: "null", reason: "empty or null"}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := codefly.InjectConfigurations(); err != nil { + t.Fatal(err) + } + if tc.document != "" { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "fixture-roles.yaml"), []byte(tc.document), 0600); err != nil { + t.Fatal(err) + } + infos, err := configurations.LoadConfigurationInformationsFromFiles(ctx, dir) + if err != nil { + t.Fatal(err) + } + if err := codefly.InjectConfigurations(&basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: infos}); err != nil { + t.Fatal(err) + } + } + k := testkit.New(t) + err := grantCompositionRoles(ctx, k.State) + if tc.reason != "" { + if err == nil || !strings.Contains(err.Error(), tc.reason) { + t.Fatalf("got %v, want %s", err, tc.reason) + } + } else if err != nil { + t.Fatal(err) + } + found := false + for _, scope := range k.State.RoleScopes("acme", "editor") { + if scope.GetResourceKind() == "ledger.entry" { + found = true + } + } + if found != tc.granted { + t.Fatalf("composition grant present = %v, want %v", found, tc.granted) + } + }) + } +} diff --git a/modules/authority/services/local/code/pkg/business/state/composition_roles.go b/modules/authority/services/local/code/pkg/business/state/composition_roles.go new file mode 100644 index 00000000..23eec659 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/state/composition_roles.go @@ -0,0 +1,90 @@ +package state + +import ( + "bytes" + "errors" + "fmt" + "io" + "slices" + "strings" + + resources "github.com/codefly-dev/authority/libraries/resources/go" + "gopkg.in/yaml.v3" +) + +// CompositionRolesDocument is the workspace configuration document a +// composition supplies to grant the fixture's organisation roles what its +// modules serve. The fixture says who exists and which roles they hold; a +// module's permissions belong to the module's own composition (its +// playground), so this repository need not name them. +const CompositionRolesDocument = "fixture-roles" + +// GrantCompositionRoles adds a composition's grants to the fixture's roles. +// The document spells roles as the fixture does (`roles:` entries naming an +// organisation, a role and its scopes). Each entry must name a role the +// fixture defines: a composition grants, it never creates a role or a person. +// The generated developer role is refused, being derived from the delivered +// catalog. It returns one line per grant, for the boot log. +func (s *State) GrantCompositionRoles(data []byte) ([]string, error) { + var doc *struct { + Roles []Role `yaml:"roles"` + } + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&doc); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("%s: %w", CompositionRolesDocument, err) + } + if doc == nil { + return nil, fmt.Errorf("%s: expected a roles document, got empty or null", CompositionRolesDocument) + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("%s: expected exactly one roles document", CompositionRolesDocument) + } + s.mu.Lock() + defer s.mu.Unlock() + granted := map[string]Role{} + var lines []string + for _, r := range doc.Roles { + key := r.Organization + "/" + r.Name + role, ok := granted[key] + if !ok { + role, ok = s.roles[key] + role.Scopes = slices.Clone(role.Scopes) + } + switch { + case !ok: + return nil, fmt.Errorf("%s: %s is not a role the fixture defines", CompositionRolesDocument, key) + case r.Generated || role.Generated: + return nil, fmt.Errorf("%s: %s is derived from the delivered catalog and takes no grant", CompositionRolesDocument, key) + case len(r.Scopes) == 0: + return nil, fmt.Errorf("%s: %s names no scope", CompositionRolesDocument, key) + } + for _, sc := range r.Scopes { + if strings.TrimSpace(sc.Kind) == "" || len(sc.Actions) == 0 { + return nil, fmt.Errorf("%s: %s grants a scope with no kind or no action", CompositionRolesDocument, key) + } + for _, action := range sc.Actions { + if strings.TrimSpace(action) == "" { + return nil, fmt.Errorf("%s: %s scope %s has an empty action", CompositionRolesDocument, key, sc.Kind) + } + } + 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, fmt.Errorf("%s: %s scope %s: %w", CompositionRolesDocument, key, sc.Kind, err) + } + role.Scopes = append(role.Scopes, sc) + lines = append(lines, fmt.Sprintf("%s: %s:%v", key, sc.Kind, sc.Actions)) + } + granted[key] = role + } + // Applied only once every entry is valid: a refused document leaves the + // fixture's roles as they were. + for key, role := range granted { + s.roles[key] = role + } + return lines, nil +} diff --git a/modules/authority/services/local/code/pkg/business/state/composition_roles_test.go b/modules/authority/services/local/code/pkg/business/state/composition_roles_test.go new file mode 100644 index 00000000..54711c63 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/state/composition_roles_test.go @@ -0,0 +1,133 @@ +package state_test + +import ( + "strings" + "testing" + + "gateway/pkg/business/state" + "gateway/pkg/business/testkit" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// A composition grants the fixture's roles what its module serves; the +// fixture never names that module. +func TestCompositionGrantsFixtureRolesWhatItsModuleServes(t *testing.T) { + k := testkit.New(t) + ledger := state.Manifest{Module: "ledger", Principal: "ledger", Kind: "module", Generation: 1, + Methods: map[string]state.MethodPolicy{ + "/ledger/Read": {Requires: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "read")}}, + "/ledger/Write": {Requires: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "write")}}, + "/ledger/Approve": {Requires: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "approve")}}, + }} + if _, err := k.State.ApplyManifest(ledger); err != nil { + t.Fatal(err) + } + if held, _ := k.State.PersonScopes("remi", "ledger"); len(held) != 0 { + t.Fatalf("the fixture alone grants the editor %v at ledger", held) + } + lines, err := k.State.GrantCompositionRoles([]byte(` +roles: + - organization: acme + name: editor + scopes: + - {kind: ledger.entry, actions: [read, write]} + - organization: acme + name: admin + scopes: + - {kind: ledger.entry, actions: [approve]} + - organization: acme + name: viewer + scopes: + - kind: ledger.entry + actions: [read] + resources: [{prefix: "public-"}] +`)) + if err != nil || len(lines) != 3 { + t.Fatalf("grant: %v %v", lines, err) + } + want := map[string]*basev0.WorkScopeV1{ + "remi": testkit.Scope("ledger.entry", "read", "write"), + "alex": testkit.Scope("ledger.entry", "approve"), + } + for person, scope := range want { + held, err := k.State.PersonScopes(person, "ledger") + if err != nil || !state.Attenuates([]*basev0.WorkScopeV1{scope}, held) { + t.Errorf("%s holds %v at ledger (%v), want %v", person, held, err, scope) + } + } + if held, _ := k.State.PersonScopes("alex", "ledger"); state.Attenuates([]*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "write")}, held) { + t.Error("a grant to the editor reached the admin") + } + for _, tc := range []struct{ person, method, reason string }{ + {"remi", "/ledger/Write", ""}, + {"alex", "/ledger/Approve", ""}, + {"maya", "/ledger/Write", "ledger.entry:write"}, + {"maya", "/ledger/Approve", "ledger.entry:approve"}, + {"remi", "/ledger/Approve", "ledger.entry:approve"}, + } { + held, err := k.State.PersonScopes(tc.person, "ledger") + if err != nil { + t.Fatal(err) + } + err = k.State.AdmitMethod("ledger", tc.method, held) + if tc.reason == "" && err != nil { + t.Errorf("%s %s: %v", tc.person, tc.method, err) + } + if tc.reason != "" && (err == nil || !strings.Contains(err.Error(), tc.reason)) { + t.Errorf("%s %s: got %v, want refusal naming %s", tc.person, tc.method, err, tc.reason) + } + } + grants, err := k.State.ResourceGrants("maya", "ledger", "ledger.entry", "read") + if err != nil || len(grants) != 1 || grants[0].Prefix != "public-" { + t.Errorf("the viewer's grant keeps its resources: %v %v", grants, err) + } + // The fixture's own grants stay: the editor still writes documents. + if held, _ := k.State.PersonScopes("remi", "documents"); !state.Attenuates([]*basev0.WorkScopeV1{testkit.Scope("documents", "write")}, held) { + t.Errorf("the fixture's grant was lost: %v", held) + } +} + +// A composition grants; it never creates a role, touches the generated +// developer role, or half-applies a document it refuses. +func TestCompositionRolesRefuseByName(t *testing.T) { + for name, tc := range map[string]struct{ doc, reason string }{ + "unknown role": {`{roles: [{organization: acme, name: auditor, scopes: [{kind: documents, actions: [read]}]}]}`, "acme/auditor is not a role the fixture defines"}, + "other org": {`{roles: [{organization: initech, name: admin, scopes: [{kind: documents, actions: [read]}]}]}`, "initech/admin is not a role the fixture defines"}, + "developer": {`{roles: [{organization: acme, name: developer, scopes: [{kind: documents, actions: [read]}]}]}`, "derived from the delivered catalog"}, + "no scope": {`{roles: [{organization: acme, name: editor}]}`, "acme/editor names no scope"}, + "no action": {`{roles: [{organization: acme, name: editor, scopes: [{kind: documents}]}]}`, "no kind or no action"}, + "empty action": {`{roles: [{organization: acme, name: editor, scopes: [{kind: documents, actions: [" "]}]}]}`, "empty action"}, + "extra document": {"{roles: []}\n---\n{roles: []}", "expected exactly one roles document"}, + "misspelt": {`{roles: [{organization: acme, name: editor, grants: [{kind: documents, actions: [read]}]}]}`, "field grants not found"}, + "bad resource": {`{roles: [{organization: acme, name: editor, scopes: [{kind: documents, actions: [read], resources: [{}]}]}]}`, "acme/editor scope documents"}, + } { + t.Run(name, func(t *testing.T) { + k := testkit.New(t) + before := k.State.RoleScopes("acme", "editor") + // A valid first entry must not survive a refused second one. + doc := strings.Replace(tc.doc, "{roles: [", "{roles: [{organization: acme, name: editor, scopes: [{kind: documents, actions: [ingest]}]}, ", 1) + _, err := k.State.GrantCompositionRoles([]byte(doc)) + if err == nil || !strings.Contains(err.Error(), tc.reason) { + t.Fatalf("got %v, want a refusal naming %q", err, tc.reason) + } + after := k.State.RoleScopes("acme", "editor") + if !state.Attenuates(before, after) || !state.Attenuates(after, before) { + t.Fatalf("a refused document changed the editor: %v → %v", before, after) + } + }) + } +} + +// An explicit empty role list grants nothing; empty or null documents are errors. +func TestEmptyCompositionRolesGrantNothing(t *testing.T) { + k := testkit.New(t) + if lines, err := k.State.GrantCompositionRoles([]byte("roles: []")); err != nil || len(lines) != 0 { + t.Fatalf("%v %v", lines, err) + } + for _, data := range []string{"", "null"} { + if _, err := k.State.GrantCompositionRoles([]byte(data)); err == nil || !strings.Contains(err.Error(), "empty or null") { + t.Fatalf("%q: expected empty or null refusal, got %v", data, err) + } + } +} diff --git a/modules/authority/services/local/code/work.go b/modules/authority/services/local/code/work.go index bc8b1198..638206c6 100644 --- a/modules/authority/services/local/code/work.go +++ b/modules/authority/services/local/code/work.go @@ -76,6 +76,9 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl if err := st.LoadFixture(ctx, data); err != nil { return nil, fmt.Errorf("fixture %q: %w", name, err) } + if err := grantCompositionRoles(ctx, st); err != nil { + return nil, err + } clean, err := approvalsFor(ctx, st) if err != nil { return nil, err @@ -116,6 +119,31 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl return clean, nil } +// grantCompositionRoles adds what the composition grants the fixture's roles: +// the workspace configuration document fixture-roles, which a module's own +// playground supplies for the permissions its module serves. A run that +// supplies none keeps the fixture's roles as they are; a document that does not +// read, or names a role the fixture does not define, refuses to boot. +func grantCompositionRoles(ctx context.Context, st *state.State) error { + w := wool.Get(ctx).In("local") + document, err := codefly.For(ctx).WorkspaceConfigurationDocument(state.CompositionRolesDocument) + if errors.Is(err, codefly.ErrConfigurationDocumentMissing) { + w.Info("the composition grants the fixture's roles nothing (no fixture-roles document)") + return nil + } + if err != nil { + return err + } + granted, err := st.GrantCompositionRoles(document) + if err != nil { + return err + } + for _, line := range granted { + w.Info("the composition grants " + line) + } + return nil +} + // approvalsFor chooses where consent lives. The store is a declared dependency // (service.codefly.yaml), so a run that composes it starts it first and // delivers its read-write connection: approvals then persist there, and a diff --git a/modules/authority/services/local/service.codefly.yaml b/modules/authority/services/local/service.codefly.yaml index 6571e447..9f3d17ac 100644 --- a/modules/authority/services/local/service.codefly.yaml +++ b/modules/authority/services/local/service.codefly.yaml @@ -10,8 +10,13 @@ agent: # connection as this service's `store` postgres/read-write-connection secret. # 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-roles: a document the composition may supply, granting the fixture's +# organisation roles what its modules serve (code/work.go +# grantCompositionRoles); a run that supplies none keeps the fixture's roles. workspace-configuration-dependencies: - federation + - fixture-roles service-dependencies: - name: store module: authority From 4d1217c0ed95a9ac7dd1edac8f6a568a1b243426 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:02:22 -0400 Subject: [PATCH 2/2] fix: the fixture names only the authority's vocabulary; a composition adds the rest (review r49) The embedded fixture granted robin, tracing, evaluation, records, documents and directory kinds, stood in for the model and installed a solution. It now holds organisations, people and roles in the authority's own vocabulary (console.identity) and nothing else. What a composition's modules and solutions need arrives through the versioned fixture-composition document (schema codefly.dev/authority/fixture-composition/v1): grants on the fixture's roles, installations and stand-in modules, each through the fixture's own path. - B1: the authority module ships an empty default document (modules/authority/configurations/local), which a composing workspace's own replaces whole. doctor reports it provided by the composed module, and a workspace composing the authority alone, with neither federation nor fixture-composition, boots ("the composition adds nothing to the fixture"). - M1: wildcard kinds and actions are refused by name. - M2: module grants, the wiki installations and the model stand-in leave the fixture. The authority playground supplies grants for the modules it composes. The test kit applies them as a composition does (testkit/testdata/composition.yaml). The test asserting the fixture's documents grant is gone; the composition tests use synthetic kinds only. - m1: an empty or null document, a null section, an unversioned document and kinds or actions outside the grammar (whitespace included) are refused. - m2: the local authority refuses to start in a pod. - m3: the document is versioned, and the README states its contract. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 +- .../local/fixture-composition.yaml | 6 + modules/authority/services/local/README.md | 78 ++++--- ...tion_roles_test.go => composition_test.go} | 32 ++- .../services/local/code/fixtures/local.yaml | 91 +------- .../code/pkg/business/state/composition.go | 211 ++++++++++++++++++ .../pkg/business/state/composition_roles.go | 90 -------- .../business/state/composition_roles_test.go | 133 ----------- .../pkg/business/state/composition_test.go | 179 +++++++++++++++ .../testkit/testdata/composition.yaml | 94 ++++++++ .../code/pkg/business/testkit/testkit.go | 60 +++-- modules/authority/services/local/code/work.go | 34 +-- .../services/local/service.codefly.yaml | 10 +- .../local/fixture-composition.yaml | 40 ++++ 14 files changed, 692 insertions(+), 383 deletions(-) create mode 100644 modules/authority/configurations/local/fixture-composition.yaml rename modules/authority/services/local/code/{composition_roles_test.go => composition_test.go} (52%) create mode 100644 modules/authority/services/local/code/pkg/business/state/composition.go delete mode 100644 modules/authority/services/local/code/pkg/business/state/composition_roles.go delete mode 100644 modules/authority/services/local/code/pkg/business/state/composition_roles_test.go create mode 100644 modules/authority/services/local/code/pkg/business/state/composition_test.go create mode 100644 modules/authority/services/local/code/pkg/business/testkit/testdata/composition.yaml create mode 100644 playground/configurations/local/fixture-composition.yaml diff --git a/README.md b/README.md index 74d898aa..05d94399 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ This repository holds two things, and only these two: 2. **The local authority** (`modules/authority/services/local`): the development implementation. One process that signs in fixture people with their roles, issues and verifies Work Contexts, knows the composition from the rendered - manifests, and holds the fixture installations in memory. No accounts + manifests, and holds the installations its composition declares in memory. No accounts service, no stores, no platform frontend. Running a solution locally is the solution, the modules it consumes, and this. @@ -130,7 +130,7 @@ design. |---|---| | The solution registers its backend and page | `/modules/_register`, `/modules/_registration-token` (gateway `gateway_module_routes.go`); `/.well-known/solution.json` and `/.well-known/capabilities` served by the runtime and proxied | | Routes to the solution | `/solutions//*` (gateway), `/api/solutions//proxy/*` (frontend), module-federation assets, the registry snapshot, the 120 s lease | -| Who may use it | `ListSolutionEntitlements` (gateway `handleSolutionEntitlements`); `InstallationService`: `InstallSolution`, `GrantInstallationToTeam`, `ListInstallations`; installations come from the fixture file | +| Who may use it | `ListSolutionEntitlements` (gateway `handleSolutionEntitlements`); `InstallationService`: `InstallSolution`, `GrantInstallationToTeam`, `ListInstallations`; installations come from the composition's fixture-composition document | | The solution is installed | host → solution `POST /_codefly/installations` with a Work Context, retried until acknowledged | | The solution acts for a viewer against a module | `StartTask` under resource and installation confinement (`x-codefly-resource`, installation header) | @@ -427,16 +427,17 @@ The local fixture includes `no-roles` in Acme for that case. The suite proves it as a matrix: the fixture states, for each person at each audience, which kind and action is allowed and which refused, and the suite asks for exactly that scope and expects the answer. The local authority's fixture carries -two organisations, a person with no roles or teams, an automatically generated -developer role, and a solution installed under different roles. The host supplies its own matrix from its +two organisations, a person with no roles or teams and an automatically generated +developer role; the test kit's composition document installs a solution under +different roles in each. The host supplies its own matrix from its own fixture. When the fixture signs, the suite composes its solution as a carrier with the authority's document carrying its roles; the sketch remains for a fixture that does not sign. -A composition grants the fixture's organisation roles what its modules serve -through the `fixture-roles` workspace configuration document -([grants a composition supplies](modules/authority/services/local/README.md#grants-a-composition-supplies)), -so a module's permissions need not be written into this repository's fixture. +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 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/modules/authority/configurations/local/fixture-composition.yaml b/modules/authority/configurations/local/fixture-composition.yaml new file mode 100644 index 00000000..d154e0ff --- /dev/null +++ b/modules/authority/configurations/local/fixture-composition.yaml @@ -0,0 +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 +# (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 82224b56..21f5fa9e 100644 --- a/modules/authority/services/local/README.md +++ b/modules/authority/services/local/README.md @@ -37,41 +37,56 @@ Generated code (`pkg/gen`, `*_gen.go`, `main.go`) comes from `codefly generate` and the agent; `work.go` and everything under `pkg/business`, `pkg/infra` and `plugins` is written here. -## Grants a composition supplies +## What a composition adds to the fixture -The fixture says who exists, which organisation roles they hold and what each -role grants of the platform's own vocabulary. What a role may do at one module -belongs to that module's composition, not to this repository: a module's -playground supplies the workspace configuration document `fixture-roles` -(`configurations/local/fixture-roles.yaml` in its workspace), spelled as the -fixture's `roles:` entries, and the local authority adds each entry's scopes to -the fixture role it names before any listener starts: +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): ```yaml -roles: +schema: codefly.dev/authority/fixture-composition/v1 +roles: # grants added to roles the fixture defines - organization: acme name: editor scopes: - - {kind: ledger.entry, actions: [write]} + - {kind: ledger.entry, actions: [read, write]} +installations: # installations of a solution the run composes, the fixture's own path + - {organization: acme, target: atlas, role: viewer, owner: alex, teams: [finance]} +modules: # a module the run supervises itself, until its own delivery + - module: oracle + kinds: [{kind: oracle.answers, actions: [read]}] ``` -A composition grants; it never creates a role or a person. An entry naming a -role the fixture does not define, the generated `developer` role, no scope, a -scope with no kind or action, an unknown field or a malformed resource matcher -refuses the boot by name, and a refused document changes no role. Empty or null -documents and additional YAML documents are refused; `roles: []` explicitly -grants nothing. The boot log -names each grant (`the composition grants acme/editor: ledger.entry:[write]`), -or says the composition supplied none. The delivery-time report below then -reads the granted roles like any other. - -This reuses Codefly's public workspace document delivery and SDK -`WorkspaceConfigurationDocument`, the fixture's `Role`/`Scope` schema, the -shared resource matcher compiler, and the existing person-scope resolver. -The delivered method catalog generates only `developer` today -(`pkg/business/state/permissions.go`); it carries no organisation-role-class -mapping for editor or admin. Composition grants fill that local fixture gap -without introducing another resolver or changing the host authority interface. +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 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: +- 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 + `developer` role (it is derived from the delivered catalog); +- no scope, or a scope with no action; +- a wildcard kind or action (`*`): a composition grants the concrete + permissions its modules serve, so viewer, editor and administrator stay + 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. + +The boot log names each effect, for example `the composition grants +acme/editor: ledger.entry:[read write]`, or says that the composition adds +nothing. The local authority refuses to start in a pod. ## Permission diagnostics during local development @@ -97,10 +112,11 @@ regressions. This does not qualify the production host or live browser journeys. Person issuance sorts and deduplicates a copy of the session's team attribution before signing. Fixture order therefore does not affect a module's ability to -consume the context; the original session remains unchanged. The local Globex -administrator explicitly holds `documents.collection:read` alongside document -read/write; the role name itself implies no additional grants. Issuer regressions -and the shared fixture matrix cover this grant and a person who lacks it. +consume the context; the original session remains unchanged. A role name implies no +grants: the test kit's composition +(`code/pkg/business/testkit/testdata/composition.yaml`) gives the Globex +administrator its collection read explicitly. Issuer regressions and the shared +fixture matrix cover that grant and a person who lacks it. ## Signed delivery lifecycle diff --git a/modules/authority/services/local/code/composition_roles_test.go b/modules/authority/services/local/code/composition_test.go similarity index 52% rename from modules/authority/services/local/code/composition_roles_test.go rename to modules/authority/services/local/code/composition_test.go index 316f7697..f89ecd9c 100644 --- a/modules/authority/services/local/code/composition_roles_test.go +++ b/modules/authority/services/local/code/composition_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "gateway/pkg/business/state" "gateway/pkg/business/testkit" "github.com/codefly-dev/core/configurations" @@ -17,26 +18,35 @@ import ( // Exercise the production boot hook through Codefly's file loader and SDK // document carrier, rather than passing fixture bytes straight to state. -func TestCompositionRolesReachBootThroughCodefly(t *testing.T) { +func TestCompositionReachesBootThroughCodefly(t *testing.T) { ctx := context.Background() t.Setenv(resources.EnvironmentPrefix, "local") t.Cleanup(func() { _ = codefly.InjectConfigurations() }) for _, tc := range []struct { - name, document, reason string - granted bool + name, document, path, reason string + granted bool }{ - {name: "absent"}, - {name: "composition", document: "roles:\n - organization: acme\n name: editor\n scopes:\n - {kind: ledger.entry, actions: [write]}\n", granted: true}, - {name: "unknown role", document: "roles: [{organization: acme, name: missing, scopes: [{kind: ledger.entry, actions: [write]}]}]", reason: "acme/missing is not a role"}, - {name: "null", document: "null", reason: "empty or null"}, + {name: "absent", reason: "no fixture-composition document was delivered"}, + {name: "shipped default", path: testkit.DefaultCompositionPath()}, + {name: "composition", document: "schema: codefly.dev/authority/fixture-composition/v1\nroles:\n - organization: acme\n name: editor\n scopes:\n - {kind: ledger.entry, actions: [write]}\n", granted: true}, + {name: "unknown role", document: "schema: codefly.dev/authority/fixture-composition/v1\nroles: [{organization: acme, name: missing, scopes: [{kind: ledger.entry, actions: [write]}]}]", reason: "acme/missing is not a role"}, + {name: "null", document: "null", reason: "not a mapping"}, + {name: "unversioned", document: "{}", reason: "schema is"}, } { t.Run(tc.name, func(t *testing.T) { if err := codefly.InjectConfigurations(); err != nil { t.Fatal(err) } + if tc.path != "" { + data, err := os.ReadFile(tc.path) + if err != nil { + t.Fatal(err) + } + tc.document = string(data) + } if tc.document != "" { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "fixture-roles.yaml"), []byte(tc.document), 0600); err != nil { + if err := os.WriteFile(filepath.Join(dir, "fixture-composition.yaml"), []byte(tc.document), 0600); err != nil { t.Fatal(err) } infos, err := configurations.LoadConfigurationInformationsFromFiles(ctx, dir) @@ -48,7 +58,11 @@ func TestCompositionRolesReachBootThroughCodefly(t *testing.T) { } } k := testkit.New(t) - err := grantCompositionRoles(ctx, k.State) + ledger := state.Manifest{Module: "ledger", Principal: "ledger", Kind: "module", Generation: 1, ResourceKinds: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "write")}} + if _, err := k.State.ApplyManifest(ledger); err != nil { + t.Fatal(err) + } + err := applyComposition(ctx, k.State) if tc.reason != "" { if err == nil || !strings.Contains(err.Error(), tc.reason) { t.Fatalf("got %v, want %s", err, tc.reason) diff --git a/modules/authority/services/local/code/fixtures/local.yaml b/modules/authority/services/local/code/fixtures/local.yaml index a347e0f3..52290c91 100644 --- a/modules/authority/services/local/code/fixtures/local.yaml +++ b/modules/authority/services/local/code/fixtures/local.yaml @@ -1,4 +1,10 @@ -# The local authority's fixture: who exists, what they hold, what is installed. +# The local authority's fixture: who exists and which organisation roles they +# hold, in the authority's own vocabulary only. What a role holds at a module, +# which solutions are installed and which modules a run supervises itself +# belong to the composition that composes them: each composing workspace adds +# them through its fixture-composition document (services/local/README.md, +# "What a composition adds to the fixture"). The organisation, role and person +# ids below are that document's contract with every composing repository. organizations: - id: acme name: Acme @@ -8,73 +14,17 @@ roles: - organization: acme name: viewer scopes: - - {kind: documents, actions: [read]} - - {kind: records.records, actions: [read]} - - {kind: records.types, actions: [read]} - - {kind: directory.entry, actions: [read]} + # The authority console: a viewer reads who they are. - {kind: console.identity, actions: [read]} - # Robin: a viewer reads the conversations they own, and runs no turn. - - {kind: robin.conversations, actions: [read]} - # Tracing: a viewer lists the organisation's agent runs, never their text. - - {kind: tracing.trace, actions: [list_completed, read]} - # Evaluation: a viewer reads the runs and the online scores. - - {kind: evaluation.runs, actions: [read]} - - {kind: evaluation.online, actions: [read]} - # A viewer reads only some document collections: the directory's, the - # wiki's, and every shared one. The authority answers this when asked - # (ListResources); the token carries the kind alone. A collection's id - # is the store's namespace, "derived" and its producer joined by a unit - # separator (\x1f). - - kind: documents.collection - actions: [read] - resources: - - {prefix: "derived\x1fdirectory"} - - {prefix: "derived\x1fwiki"} - - {pattern: "shared-[a-z]+"} - organization: acme name: editor - scopes: - - {kind: documents, actions: [read, write]} - - {kind: documents.collection, actions: [read]} - - {kind: records.records, actions: [read, write]} - - {kind: records.types, actions: [read]} - - {kind: directory.entry, actions: [read, write]} - # An editor invokes the model: robin's model child is the authority's to - # grant from what the editor holds at the model, and a turn the editor - # runs calls a profile. - - {kind: modelservice.profiles, actions: [read, invoke]} - # Robin: an editor runs turns and keeps their conversations. - - {kind: robin.tasks, actions: [execute, read]} - - {kind: robin.runs, actions: [read, start, cancel]} - - {kind: robin.conversations, actions: [read, write, share, delete]} - - {kind: robin.intents, actions: [read, run]} - # Tracing: an editor reads their organisation's agent runs, text - # included where the composition keeps it. - - {kind: tracing.trace, actions: [list_completed, read, read_content]} - # Evaluation: an editor runs an evaluation (creates a run, submits its - # outputs, completes it) and reads the scores. - - {kind: evaluation.runs, actions: [read, write]} - - {kind: evaluation.online, actions: [read]} + scopes: [] - organization: acme name: admin - scopes: - - {kind: documents, actions: [read, write, ingest]} - - {kind: documents.collection, actions: [read, transfer, admin]} - # Robin: an administrator sees and changes what the intent store holds. - - {kind: robin.intents, actions: [read, read_config, import, configure, activate]} - # Evaluation: an administrator publishes definitions, registers and - # freezes datasets, labels them and approves labels (a reviewer who is - # not the author); the developer, who writes them, holds everything. - - {kind: evaluation.definitions, actions: [publish]} - - {kind: evaluation.datasets, actions: [write]} - - {kind: evaluation.labels, actions: [write, approve, admin]} - - {kind: evaluation.runs, actions: [read]} - - {kind: evaluation.online, actions: [read]} + scopes: [] - organization: globex name: admin - scopes: - - {kind: documents, actions: [read, write]} - - {kind: documents.collection, actions: [read]} + scopes: [] # Generated from the current delivered catalog, recomputed on each delivery, # in Acme. A playground signs its person in as dev. - organization: acme @@ -129,22 +79,3 @@ people: organization: acme roles: [developer] teams: [engineering] -installations: - # Acme's wiki reaches finance and engineering as viewers; its owner installs. - - organization: acme - target: wiki - role: viewer - owner: alex - teams: [finance, engineering] - - organization: globex - target: wiki - role: editor - owner: zoe - teams: [ops] - -# A model a local run supervises itself (a playground's bridge to its model -# provider) is an audience like any composed module. -modules: - - module: model - kinds: - - {kind: modelservice.profiles, actions: [read, invoke]} diff --git a/modules/authority/services/local/code/pkg/business/state/composition.go b/modules/authority/services/local/code/pkg/business/state/composition.go new file mode 100644 index 00000000..0e98caf2 --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/state/composition.go @@ -0,0 +1,211 @@ +package state + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "regexp" + "slices" + + resources "github.com/codefly-dev/authority/libraries/resources/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 +// 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" + +// 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"` +} + +// 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 +// 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) + if err != nil { + return nil, fmt.Errorf("%s: %w", CompositionDocument, err) + } + roles, lines, err := s.composedRoles(doc) + if err != nil { + return nil, fmt.Errorf("%s: %w", CompositionDocument, err) + } + more, err := s.checkComposedSetup(doc) + if err != nil { + return nil, fmt.Errorf("%s: %w", CompositionDocument, err) + } + s.mu.Lock() + for key, role := range roles { + s.roles[key] = role + } + s.mu.Unlock() + if len(doc.Installations) > 0 || len(doc.Modules) > 0 { + setup, err := yaml.Marshal(fixtureFile{Installations: doc.Installations, Modules: doc.Modules}) + if err != nil { + return nil, err + } + if err := s.LoadFixture(ctx, setup); err != nil { + return nil, fmt.Errorf("%s: %w", CompositionDocument, err) + } + } + 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) { + s.mu.RLock() + defer s.mu.RUnlock() + granted := map[string]Role{} + var lines []string + for _, r := range doc.Roles { + key := r.Organization + "/" + r.Name + role, ok := granted[key] + if !ok { + role, ok = s.roles[key] + role.Scopes = slices.Clone(role.Scopes) + } + switch { + case !ok: + return nil, nil, fmt.Errorf("%s is not a role the fixture defines", key) + case r.Generated || 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)) + } + granted[key] = role + } + 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) { + s.mu.RLock() + defer s.mu.RUnlock() + var lines []string + for _, i := range doc.Installations { + owner, ok := s.people[i.Owner] + switch { + case i.Target == "" || i.Role == "": + return nil, fmt.Errorf("an installation names no target or no role") + case s.orgs[i.Organization] == "": + 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: + return nil, fmt.Errorf("installation of %s: %q is not a person of %s in the fixture", i.Target, i.Owner, i.Organization) + } + lines = append(lines, fmt.Sprintf("installs %s at %s as %s, for %v", i.Target, i.Organization, i.Role, i.Teams)) + } + 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/composition_roles.go b/modules/authority/services/local/code/pkg/business/state/composition_roles.go deleted file mode 100644 index 23eec659..00000000 --- a/modules/authority/services/local/code/pkg/business/state/composition_roles.go +++ /dev/null @@ -1,90 +0,0 @@ -package state - -import ( - "bytes" - "errors" - "fmt" - "io" - "slices" - "strings" - - resources "github.com/codefly-dev/authority/libraries/resources/go" - "gopkg.in/yaml.v3" -) - -// CompositionRolesDocument is the workspace configuration document a -// composition supplies to grant the fixture's organisation roles what its -// modules serve. The fixture says who exists and which roles they hold; a -// module's permissions belong to the module's own composition (its -// playground), so this repository need not name them. -const CompositionRolesDocument = "fixture-roles" - -// GrantCompositionRoles adds a composition's grants to the fixture's roles. -// The document spells roles as the fixture does (`roles:` entries naming an -// organisation, a role and its scopes). Each entry must name a role the -// fixture defines: a composition grants, it never creates a role or a person. -// The generated developer role is refused, being derived from the delivered -// catalog. It returns one line per grant, for the boot log. -func (s *State) GrantCompositionRoles(data []byte) ([]string, error) { - var doc *struct { - Roles []Role `yaml:"roles"` - } - decoder := yaml.NewDecoder(bytes.NewReader(data)) - decoder.KnownFields(true) - if err := decoder.Decode(&doc); err != nil && !errors.Is(err, io.EOF) { - return nil, fmt.Errorf("%s: %w", CompositionRolesDocument, err) - } - if doc == nil { - return nil, fmt.Errorf("%s: expected a roles document, got empty or null", CompositionRolesDocument) - } - var extra any - if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { - return nil, fmt.Errorf("%s: expected exactly one roles document", CompositionRolesDocument) - } - s.mu.Lock() - defer s.mu.Unlock() - granted := map[string]Role{} - var lines []string - for _, r := range doc.Roles { - key := r.Organization + "/" + r.Name - role, ok := granted[key] - if !ok { - role, ok = s.roles[key] - role.Scopes = slices.Clone(role.Scopes) - } - switch { - case !ok: - return nil, fmt.Errorf("%s: %s is not a role the fixture defines", CompositionRolesDocument, key) - case r.Generated || role.Generated: - return nil, fmt.Errorf("%s: %s is derived from the delivered catalog and takes no grant", CompositionRolesDocument, key) - case len(r.Scopes) == 0: - return nil, fmt.Errorf("%s: %s names no scope", CompositionRolesDocument, key) - } - for _, sc := range r.Scopes { - if strings.TrimSpace(sc.Kind) == "" || len(sc.Actions) == 0 { - return nil, fmt.Errorf("%s: %s grants a scope with no kind or no action", CompositionRolesDocument, key) - } - for _, action := range sc.Actions { - if strings.TrimSpace(action) == "" { - return nil, fmt.Errorf("%s: %s scope %s has an empty action", CompositionRolesDocument, key, sc.Kind) - } - } - 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, fmt.Errorf("%s: %s scope %s: %w", CompositionRolesDocument, key, sc.Kind, err) - } - role.Scopes = append(role.Scopes, sc) - lines = append(lines, fmt.Sprintf("%s: %s:%v", key, sc.Kind, sc.Actions)) - } - granted[key] = role - } - // Applied only once every entry is valid: a refused document leaves the - // fixture's roles as they were. - for key, role := range granted { - s.roles[key] = role - } - return lines, nil -} diff --git a/modules/authority/services/local/code/pkg/business/state/composition_roles_test.go b/modules/authority/services/local/code/pkg/business/state/composition_roles_test.go deleted file mode 100644 index 54711c63..00000000 --- a/modules/authority/services/local/code/pkg/business/state/composition_roles_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package state_test - -import ( - "strings" - "testing" - - "gateway/pkg/business/state" - "gateway/pkg/business/testkit" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" -) - -// A composition grants the fixture's roles what its module serves; the -// fixture never names that module. -func TestCompositionGrantsFixtureRolesWhatItsModuleServes(t *testing.T) { - k := testkit.New(t) - ledger := state.Manifest{Module: "ledger", Principal: "ledger", Kind: "module", Generation: 1, - Methods: map[string]state.MethodPolicy{ - "/ledger/Read": {Requires: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "read")}}, - "/ledger/Write": {Requires: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "write")}}, - "/ledger/Approve": {Requires: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "approve")}}, - }} - if _, err := k.State.ApplyManifest(ledger); err != nil { - t.Fatal(err) - } - if held, _ := k.State.PersonScopes("remi", "ledger"); len(held) != 0 { - t.Fatalf("the fixture alone grants the editor %v at ledger", held) - } - lines, err := k.State.GrantCompositionRoles([]byte(` -roles: - - organization: acme - name: editor - scopes: - - {kind: ledger.entry, actions: [read, write]} - - organization: acme - name: admin - scopes: - - {kind: ledger.entry, actions: [approve]} - - organization: acme - name: viewer - scopes: - - kind: ledger.entry - actions: [read] - resources: [{prefix: "public-"}] -`)) - if err != nil || len(lines) != 3 { - t.Fatalf("grant: %v %v", lines, err) - } - want := map[string]*basev0.WorkScopeV1{ - "remi": testkit.Scope("ledger.entry", "read", "write"), - "alex": testkit.Scope("ledger.entry", "approve"), - } - for person, scope := range want { - held, err := k.State.PersonScopes(person, "ledger") - if err != nil || !state.Attenuates([]*basev0.WorkScopeV1{scope}, held) { - t.Errorf("%s holds %v at ledger (%v), want %v", person, held, err, scope) - } - } - if held, _ := k.State.PersonScopes("alex", "ledger"); state.Attenuates([]*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "write")}, held) { - t.Error("a grant to the editor reached the admin") - } - for _, tc := range []struct{ person, method, reason string }{ - {"remi", "/ledger/Write", ""}, - {"alex", "/ledger/Approve", ""}, - {"maya", "/ledger/Write", "ledger.entry:write"}, - {"maya", "/ledger/Approve", "ledger.entry:approve"}, - {"remi", "/ledger/Approve", "ledger.entry:approve"}, - } { - held, err := k.State.PersonScopes(tc.person, "ledger") - if err != nil { - t.Fatal(err) - } - err = k.State.AdmitMethod("ledger", tc.method, held) - if tc.reason == "" && err != nil { - t.Errorf("%s %s: %v", tc.person, tc.method, err) - } - if tc.reason != "" && (err == nil || !strings.Contains(err.Error(), tc.reason)) { - t.Errorf("%s %s: got %v, want refusal naming %s", tc.person, tc.method, err, tc.reason) - } - } - grants, err := k.State.ResourceGrants("maya", "ledger", "ledger.entry", "read") - if err != nil || len(grants) != 1 || grants[0].Prefix != "public-" { - t.Errorf("the viewer's grant keeps its resources: %v %v", grants, err) - } - // The fixture's own grants stay: the editor still writes documents. - if held, _ := k.State.PersonScopes("remi", "documents"); !state.Attenuates([]*basev0.WorkScopeV1{testkit.Scope("documents", "write")}, held) { - t.Errorf("the fixture's grant was lost: %v", held) - } -} - -// A composition grants; it never creates a role, touches the generated -// developer role, or half-applies a document it refuses. -func TestCompositionRolesRefuseByName(t *testing.T) { - for name, tc := range map[string]struct{ doc, reason string }{ - "unknown role": {`{roles: [{organization: acme, name: auditor, scopes: [{kind: documents, actions: [read]}]}]}`, "acme/auditor is not a role the fixture defines"}, - "other org": {`{roles: [{organization: initech, name: admin, scopes: [{kind: documents, actions: [read]}]}]}`, "initech/admin is not a role the fixture defines"}, - "developer": {`{roles: [{organization: acme, name: developer, scopes: [{kind: documents, actions: [read]}]}]}`, "derived from the delivered catalog"}, - "no scope": {`{roles: [{organization: acme, name: editor}]}`, "acme/editor names no scope"}, - "no action": {`{roles: [{organization: acme, name: editor, scopes: [{kind: documents}]}]}`, "no kind or no action"}, - "empty action": {`{roles: [{organization: acme, name: editor, scopes: [{kind: documents, actions: [" "]}]}]}`, "empty action"}, - "extra document": {"{roles: []}\n---\n{roles: []}", "expected exactly one roles document"}, - "misspelt": {`{roles: [{organization: acme, name: editor, grants: [{kind: documents, actions: [read]}]}]}`, "field grants not found"}, - "bad resource": {`{roles: [{organization: acme, name: editor, scopes: [{kind: documents, actions: [read], resources: [{}]}]}]}`, "acme/editor scope documents"}, - } { - t.Run(name, func(t *testing.T) { - k := testkit.New(t) - before := k.State.RoleScopes("acme", "editor") - // A valid first entry must not survive a refused second one. - doc := strings.Replace(tc.doc, "{roles: [", "{roles: [{organization: acme, name: editor, scopes: [{kind: documents, actions: [ingest]}]}, ", 1) - _, err := k.State.GrantCompositionRoles([]byte(doc)) - if err == nil || !strings.Contains(err.Error(), tc.reason) { - t.Fatalf("got %v, want a refusal naming %q", err, tc.reason) - } - after := k.State.RoleScopes("acme", "editor") - if !state.Attenuates(before, after) || !state.Attenuates(after, before) { - t.Fatalf("a refused document changed the editor: %v → %v", before, after) - } - }) - } -} - -// An explicit empty role list grants nothing; empty or null documents are errors. -func TestEmptyCompositionRolesGrantNothing(t *testing.T) { - k := testkit.New(t) - if lines, err := k.State.GrantCompositionRoles([]byte("roles: []")); err != nil || len(lines) != 0 { - t.Fatalf("%v %v", lines, err) - } - for _, data := range []string{"", "null"} { - if _, err := k.State.GrantCompositionRoles([]byte(data)); err == nil || !strings.Contains(err.Error(), "empty or null") { - t.Fatalf("%q: expected empty or null refusal, got %v", data, err) - } - } -} diff --git a/modules/authority/services/local/code/pkg/business/state/composition_test.go b/modules/authority/services/local/code/pkg/business/state/composition_test.go new file mode 100644 index 00000000..e202c25a --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/state/composition_test.go @@ -0,0 +1,179 @@ +package state_test + +import ( + "context" + "os" + "strings" + "testing" + + "gateway/pkg/business/state" + "gateway/pkg/business/testkit" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +const head = "schema: codefly.dev/authority/fixture-composition/v1\n" + +// fixtureOnly is a state holding the embedded fixture and one synthetic +// module, ledger, that the fixture never names. +func fixtureOnly(t *testing.T) *testkit.Kit { + t.Helper() + k := testkit.Bare(t) + data, err := os.ReadFile(testkit.FixturePath()) + if err != nil { + t.Fatal(err) + } + if err := k.State.LoadFixture(context.Background(), data); err != nil { + t.Fatal(err) + } + ledger := state.Manifest{Module: "ledger", Principal: "ledger", Kind: "module", Generation: 1, + Methods: map[string]state.MethodPolicy{ + "/ledger/Read": {Requires: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "read")}}, + "/ledger/Write": {Requires: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "write")}}, + "/ledger/Approve": {Requires: []*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "approve")}}, + }} + if _, err := k.State.ApplyManifest(ledger); err != nil { + t.Fatal(err) + } + return k +} + +// The embedded fixture grants nothing at a module; a composition grants the +// fixture's roles exactly what it names. +func TestCompositionGrantsFixtureRolesWhatItsModuleServes(t *testing.T) { + k := fixtureOnly(t) + for _, person := range []string{"maya", "remi", "alex", "zoe"} { + if held, _ := k.State.PersonScopes(person, "ledger"); len(held) != 0 { + t.Fatalf("the fixture alone grants %s %v at ledger", person, held) + } + } + lines, err := k.State.ApplyComposition(context.Background(), []byte(head+` +roles: + - organization: acme + name: editor + scopes: + - {kind: ledger.entry, actions: [read, write]} + - organization: acme + name: admin + scopes: + - {kind: ledger.entry, actions: [approve]} + - organization: acme + name: viewer + scopes: + - kind: ledger.entry + actions: [read] + resources: [{prefix: "public-"}] +`)) + if err != nil || len(lines) != 3 { + t.Fatalf("apply: %v %v", lines, err) + } + for person, scope := range map[string]*basev0.WorkScopeV1{ + "remi": testkit.Scope("ledger.entry", "read", "write"), + "alex": testkit.Scope("ledger.entry", "approve"), + } { + held, err := k.State.PersonScopes(person, "ledger") + if err != nil || !state.Attenuates([]*basev0.WorkScopeV1{scope}, held) { + t.Errorf("%s holds %v at ledger (%v), want %v", person, held, err, scope) + } + } + if held, _ := k.State.PersonScopes("alex", "ledger"); state.Attenuates([]*basev0.WorkScopeV1{testkit.Scope("ledger.entry", "write")}, held) { + t.Error("a grant to the editor reached the admin") + } + if err := k.State.AdmitMethod("ledger", "/ledger/Approve", mustHeld(t, k, "remi")); err == nil { + t.Error("the editor was admitted to the admin's method") + } + grants, err := k.State.ResourceGrants("maya", "ledger", "ledger.entry", "read") + if err != nil || len(grants) != 1 || grants[0].Prefix != "public-" { + t.Errorf("the viewer's grant keeps its resources: %v %v", grants, err) + } + // The fixture's own vocabulary stays. + if scopes := k.State.RoleScopes("acme", "viewer"); !state.Attenuates([]*basev0.WorkScopeV1{testkit.Scope("console.identity", "read")}, scopes) { + t.Errorf("the fixture's console grant was lost: %v", scopes) + } +} + +func mustHeld(t *testing.T, k *testkit.Kit, person string) []*basev0.WorkScopeV1 { + t.Helper() + held, err := k.State.PersonScopes(person, "ledger") + if err != nil { + t.Fatal(err) + } + return held +} + +// Installations and stand-in modules take the fixture's own path. +func TestCompositionInstallsItsSolutionsAndStandsInForModules(t *testing.T) { + k := fixtureOnly(t) + lines, err := k.State.ApplyComposition(context.Background(), []byte(head+` +installations: + - {organization: acme, target: atlas, role: viewer, owner: alex, teams: [finance]} +modules: + - module: oracle + kinds: [{kind: oracle.answers, actions: [read]}] +`)) + if err != nil || len(lines) != 2 { + t.Fatalf("apply: %v %v", lines, err) + } + if err := k.State.AdmitMethod("oracle", "/oracle/Read", nil); err == nil || !strings.Contains(err.Error(), "no method policy") { + t.Errorf("the stand-in oracle is not composed: %v", err) + } +} + +// A composition grants concrete permissions to roles the fixture defines, and +// a refused document changes nothing. +func TestCompositionRefusesByName(t *testing.T) { + valid := `{organization: acme, name: editor, scopes: [{kind: ledger.entry, actions: [read]}]}` + for name, tc := range map[string]struct{ doc, reason string }{ + "empty": {"", "the document is empty"}, + "null": {"null", "not a mapping"}, + "empty mapping": {"{}", "schema is \"\""}, + "no schema": {`{roles: [` + valid + `]}`, "schema is \"\""}, + "other schema": {"schema: codefly.dev/authority/fixture-composition/v2\n", "this authority reads"}, + "null roles": {head + "roles: null\n", "roles is null"}, + "null installs": {head + "installations:\n", "installations is null"}, + "misspelt": {head + "grants: []\n", "field grants not found"}, + "unknown role": {head + `roles: [{organization: acme, name: auditor, scopes: [{kind: ledger.entry, actions: [read]}]}]`, "acme/auditor is not a role the fixture defines"}, + "other org": {head + `roles: [{organization: initech, name: admin, scopes: [{kind: ledger.entry, actions: [read]}]}]`, "initech/admin is not a role"}, + "developer": {head + `roles: [{organization: acme, name: developer, scopes: [{kind: ledger.entry, actions: [read]}]}]`, "derived from the delivered catalog"}, + "no scope": {head + `roles: [{organization: acme, name: editor}]`, "acme/editor names no scope"}, + "no action": {head + `roles: [{organization: acme, name: editor, scopes: [{kind: ledger.entry}]}]`, "names no action"}, + "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 kind": {head + `roles: [{organization: acme, name: editor, scopes: [{kind: " ledger.entry", actions: [read]}]}]`, "is not a kind"}, + "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"}, + "stranger owner": {head + `installations: [{organization: acme, target: atlas, role: viewer, owner: zoe}]`, "not a person of acme"}, + "no target": {head + `installations: [{organization: acme, role: viewer, owner: alex}]`, "no target or no role"}, + "wildcard stand-in": {head + `modules: [{module: oracle, kinds: [{kind: oracle.answers, actions: ["*"]}]}]`, "wildcard action"}, + } { + t.Run(name, func(t *testing.T) { + k := fixtureOnly(t) + before := k.State.RoleScopes("acme", "editor") + doc := tc.doc + // A valid first entry must not survive a refused later one. + if strings.HasPrefix(doc, head+"roles: [") { + doc = strings.Replace(doc, "roles: [", "roles: ["+valid+", ", 1) + } + _, err := k.State.ApplyComposition(context.Background(), []byte(doc)) + if err == nil || !strings.Contains(err.Error(), tc.reason) || !strings.Contains(err.Error(), state.CompositionDocument) { + t.Fatalf("got %v, want a refusal naming %q", err, tc.reason) + } + after := k.State.RoleScopes("acme", "editor") + if !state.Attenuates(before, after) || !state.Attenuates(after, before) { + t.Fatalf("a refused document changed the editor: %v → %v", before, after) + } + }) + } +} + +// The module's shipped default adds nothing. +func TestTheShippedDefaultAddsNothing(t *testing.T) { + k := fixtureOnly(t) + data, err := os.ReadFile(testkit.DefaultCompositionPath()) + if err != nil { + t.Fatal(err) + } + if lines, err := k.State.ApplyComposition(context.Background(), data); err != nil || len(lines) != 0 { + t.Fatalf("%v %v", lines, err) + } +} diff --git a/modules/authority/services/local/code/pkg/business/testkit/testdata/composition.yaml b/modules/authority/services/local/code/pkg/business/testkit/testdata/composition.yaml new file mode 100644 index 00000000..539f3abb --- /dev/null +++ b/modules/authority/services/local/code/pkg/business/testkit/testdata/composition.yaml @@ -0,0 +1,94 @@ +# The test kit's composition: what the suite's composed modules and solution +# add to the fixture, through the same fixture-composition document a +# composing workspace supplies (state.ApplyComposition). The embedded fixture +# names none of it. +schema: codefly.dev/authority/fixture-composition/v1 +roles: + - organization: acme + name: viewer + scopes: + - {kind: documents, actions: [read]} + - {kind: records.records, actions: [read]} + - {kind: records.types, actions: [read]} + - {kind: directory.entry, actions: [read]} + # Robin: a viewer reads the conversations they own, and runs no turn. + - {kind: robin.conversations, actions: [read]} + # Tracing: a viewer lists the organisation's agent runs, never their text. + - {kind: tracing.trace, actions: [list_completed, read]} + # Evaluation: a viewer reads the runs and the online scores. + - {kind: evaluation.runs, actions: [read]} + - {kind: evaluation.online, actions: [read]} + # A viewer reads only some document collections: the directory's, the + # wiki's, and every shared one. The authority answers this when asked + # (ListResources); the token carries the kind alone. A collection's id + # is the store's namespace, "derived" and its producer joined by a unit + # separator (\x1f). + - kind: documents.collection + actions: [read] + resources: + - {prefix: "derived\x1fdirectory"} + - {prefix: "derived\x1fwiki"} + - {pattern: "shared-[a-z]+"} + - organization: acme + name: editor + scopes: + - {kind: documents, actions: [read, write]} + - {kind: documents.collection, actions: [read]} + - {kind: records.records, actions: [read, write]} + - {kind: records.types, actions: [read]} + - {kind: directory.entry, actions: [read, write]} + # An editor invokes the model: robin's model child is the authority's to + # grant from what the editor holds at the model, and a turn the editor + # runs calls a profile. + - {kind: modelservice.profiles, actions: [read, invoke]} + # Robin: an editor runs turns and keeps their conversations. + - {kind: robin.tasks, actions: [execute, read]} + - {kind: robin.runs, actions: [read, start, cancel]} + - {kind: robin.conversations, actions: [read, write, share, delete]} + - {kind: robin.intents, actions: [read, run]} + # Tracing: an editor reads their organisation's agent runs, text + # included where the composition keeps it. + - {kind: tracing.trace, actions: [list_completed, read, read_content]} + # Evaluation: an editor runs an evaluation (creates a run, submits its + # outputs, completes it) and reads the scores. + - {kind: evaluation.runs, actions: [read, write]} + - {kind: evaluation.online, actions: [read]} + - organization: acme + name: admin + scopes: + - {kind: documents, actions: [read, write, ingest]} + - {kind: documents.collection, actions: [read, transfer, admin]} + # Robin: an administrator sees and changes what the intent store holds. + - {kind: robin.intents, actions: [read, read_config, import, configure, activate]} + # Evaluation: an administrator publishes definitions, registers and + # freezes datasets, labels them and approves labels (a reviewer who is + # not the author); the developer, who writes them, holds everything. + - {kind: evaluation.definitions, actions: [publish]} + - {kind: evaluation.datasets, actions: [write]} + - {kind: evaluation.labels, actions: [write, approve, admin]} + - {kind: evaluation.runs, actions: [read]} + - {kind: evaluation.online, actions: [read]} + - organization: globex + name: admin + scopes: + - {kind: documents, actions: [read, write]} + - {kind: documents.collection, actions: [read]} +installations: + # Acme's wiki reaches finance and engineering as viewers; its owner installs. + - organization: acme + target: wiki + role: viewer + owner: alex + teams: [finance, engineering] + - organization: globex + target: wiki + role: editor + owner: zoe + teams: [ops] + +# A model a local run supervises itself (a playground's bridge to its model +# provider) is an audience like any composed module. +modules: + - module: model + kinds: + - {kind: modelservice.profiles, actions: [read, invoke]} diff --git a/modules/authority/services/local/code/pkg/business/testkit/testkit.go b/modules/authority/services/local/code/pkg/business/testkit/testkit.go index 9068203b..836e015b 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -253,8 +253,21 @@ func FixturePath() string { return filepath.Join(filepath.Dir(file), "..", "..", "..", "fixtures", "local.yaml") } -// New composes documents, model and the fixture, then installs the wiki from it. -func New(t *testing.T) *Kit { +// CompositionPath is the test kit's fixture-composition document. +func CompositionPath() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "testdata", "composition.yaml") +} + +// DefaultCompositionPath is the empty fixture-composition document the +// authority module ships for a composition that supplies none. +func DefaultCompositionPath() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "..", "..", "configurations", "local", "fixture-composition.yaml") +} + +// Bare is a state and an issuer with nothing composed and no fixture loaded. +func Bare(t *testing.T) *Kit { t.Helper() launcher, err := native.NewIssuer() if err != nil { @@ -279,22 +292,39 @@ func New(t *testing.T) *Kit { if err != nil { t.Fatal(err) } + return &Kit{State: st, Launcher: launcher, Now: now, + // The clock starts at a fixed instant, for readable timestamps, and + // advances with the wall clock, so a window closes as it would on a laptop. + Issuer: issuer.New(st, Audience, "local-1", signing, func() time.Time { return now.Add(time.Since(started)) })} +} + +// New composes the suite's modules, loads the fixture, and applies the test +// kit's composition document, which grants their kinds and installs the wiki. +func New(t *testing.T) *Kit { + t.Helper() + k := Bare(t) for _, m := range []state.Manifest{Model(1), Runtime(1), Records(1), OrgDirectory(1), Documents(1), Wiki(1), Evaluation(1)} { - if _, err := st.ApplyManifest(m); err != nil { + if _, err := k.State.ApplyManifest(m); err != nil { t.Fatal(err) } } - data, err := os.ReadFile(FixturePath()) - if err != nil { - t.Fatal(err) - } - // The fixture must be loaded after the modules are composed, because it - // installs the wiki and a module installation exists per organisation. - if err := st.LoadFixture(context.Background(), data); err != nil { - t.Fatal(err) + // The fixture and the composition are loaded after the modules are + // composed: a module installation exists per organisation, and the + // composition installs the wiki. + for _, load := range []struct { + path string + apply func([]byte) error + }{ + {FixturePath(), func(data []byte) error { return k.State.LoadFixture(context.Background(), data) }}, + {CompositionPath(), func(data []byte) error { _, err := k.State.ApplyComposition(context.Background(), data); return err }}, + } { + data, err := os.ReadFile(load.path) + if err != nil { + t.Fatal(err) + } + if err := load.apply(data); err != nil { + t.Fatal(err) + } } - return &Kit{State: st, Launcher: launcher, Now: now, - // The clock starts at a fixed instant, for readable timestamps, and - // advances with the wall clock, so a window closes as it would on a laptop. - Issuer: issuer.New(st, Audience, "local-1", signing, func() time.Time { return now.Add(time.Since(started)) })} + return k } diff --git a/modules/authority/services/local/code/work.go b/modules/authority/services/local/code/work.go index 638206c6..a059db07 100644 --- a/modules/authority/services/local/code/work.go +++ b/modules/authority/services/local/code/work.go @@ -48,6 +48,11 @@ func init() { // key for this run, and the issuer over them. The registry's plugins read the // result through the wiring package. func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Clean, error) { + // The fixture's people and the composition's grants are a laptop's: never + // boot them in a pod, whatever anchor it was handed. + if native.InPod() { + return nil, errors.New("the local authority runs on a laptop only: it refuses to start in a pod") + } anchorPath := os.Getenv(native.AnchorEnvironment) if anchorPath == "" { return nil, fmt.Errorf("no launcher anchor: %s is unset, so no workload could be attested; run this service with codefly run", native.AnchorEnvironment) @@ -76,7 +81,7 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl if err := st.LoadFixture(ctx, data); err != nil { return nil, fmt.Errorf("fixture %q: %w", name, err) } - if err := grantCompositionRoles(ctx, st); err != nil { + if err := applyComposition(ctx, st); err != nil { return nil, err } clean, err := approvalsFor(ctx, st) @@ -119,27 +124,30 @@ func configureLocalAuthority(ctx context.Context, _ *adapters.Configuration) (Cl return clean, nil } -// grantCompositionRoles adds what the composition grants the fixture's roles: -// the workspace configuration document fixture-roles, which a module's own -// playground supplies for the permissions its module serves. A run that -// supplies none keeps the fixture's roles as they are; a document that does not -// read, or names a role the fixture does not define, refuses to boot. -func grantCompositionRoles(ctx context.Context, st *state.State) error { +// 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. +func applyComposition(ctx context.Context, st *state.State) error { w := wool.Get(ctx).In("local") - document, err := codefly.For(ctx).WorkspaceConfigurationDocument(state.CompositionRolesDocument) + document, err := codefly.For(ctx).WorkspaceConfigurationDocument(state.CompositionDocument) if errors.Is(err, codefly.ErrConfigurationDocumentMissing) { - w.Info("the composition grants the fixture's roles nothing (no fixture-roles document)") - return nil + return fmt.Errorf("no %s document was delivered; the authority module ships a default, so this run did not compose it", state.CompositionDocument) } if err != nil { return err } - granted, err := st.GrantCompositionRoles(document) + lines, err := st.ApplyComposition(ctx, document) if err != nil { return err } - for _, line := range granted { - w.Info("the composition grants " + line) + if len(lines) == 0 { + w.Info("the composition adds nothing to the fixture") + } + for _, line := range lines { + w.Info("the composition " + line) } return nil } diff --git a/modules/authority/services/local/service.codefly.yaml b/modules/authority/services/local/service.codefly.yaml index 9f3d17ac..50533ae7 100644 --- a/modules/authority/services/local/service.codefly.yaml +++ b/modules/authority/services/local/service.codefly.yaml @@ -11,12 +11,14 @@ 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-roles: a document the composition may supply, granting the fixture's -# organisation roles what its modules serve (code/work.go -# grantCompositionRoles); a run that supplies none keeps the fixture's roles. +# 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). workspace-configuration-dependencies: - federation - - fixture-roles + - fixture-composition service-dependencies: - name: store module: authority diff --git a/playground/configurations/local/fixture-composition.yaml b/playground/configurations/local/fixture-composition.yaml new file mode 100644 index 00000000..38dc5fbd --- /dev/null +++ b/playground/configurations/local/fixture-composition.yaml @@ -0,0 +1,40 @@ +# What this playground adds to the local authority's fixture for the modules it +# composes: the document store, the record store and the organisation +# directory (services/local/README.md, "What a composition adds to the +# fixture"). The fixture itself names none of their kinds. +schema: codefly.dev/authority/fixture-composition/v1 +roles: + - organization: acme + name: viewer + scopes: + - {kind: documents, actions: [read]} + - {kind: records.records, actions: [read]} + - {kind: records.types, actions: [read]} + - {kind: directory.entry, actions: [read]} + # A viewer reads only some document collections: the directory's and every + # shared one. The authority answers this when asked (ListResources); the + # token carries the kind alone. A collection's id is the store's namespace, + # "derived" and its producer joined by a unit separator (\x1f). + - kind: documents.collection + actions: [read] + resources: + - {prefix: "derived\x1fdirectory"} + - {pattern: "shared-[a-z]+"} + - organization: acme + name: editor + scopes: + - {kind: documents, actions: [read, write]} + - {kind: documents.collection, actions: [read]} + - {kind: records.records, actions: [read, write]} + - {kind: records.types, actions: [read]} + - {kind: directory.entry, actions: [read, write]} + - organization: acme + name: admin + scopes: + - {kind: documents, actions: [read, write, ingest]} + - {kind: documents.collection, actions: [read, transfer, admin]} + - organization: globex + name: admin + scopes: + - {kind: documents, actions: [read, write]} + - {kind: documents.collection, actions: [read]}