diff --git a/module/services/accounts/code/fixtures/installations.go b/module/services/accounts/code/fixtures/installations.go index ad53aba2b..42ec68cba 100644 --- a/module/services/accounts/code/fixtures/installations.go +++ b/module/services/accounts/code/fixtures/installations.go @@ -218,9 +218,19 @@ func (i *Installations) coreInstall(ctx context.Context, row pendingInstallation return false, err } approval.OperationID = business.NewIDString() + // An installation the core holds pending or active is continued with the + // approval it was opened under: the core replays an active same-operation + // install idempotently, and the host's plan guard refuses a fresh + // operation id against it. A revoked or absent record takes the fresh + // review's approval: a revoked installation's receipt names an operation + // the core refuses for good (its revision moved), so reusing it parked a + // fixture on that refusal on every pass after an uninstall. if store, ok := i.service.Store().(business.CanonicalInstallationSetupStore); ok { - if plan, err := store.SolutionInstallationPlan(ctx, row.orgID, authority.StableInstallationID(row.orgID, row.declared.Target)); err == nil { - approval = plan.Approval + id := authority.StableInstallationID(row.orgID, row.declared.Target) + if all, err := core.Installations(ctx, caller, row.orgID); err == nil && attemptContinues(all, id) { + if plan, err := store.SolutionInstallationPlan(ctx, row.orgID, id); err == nil { + approval = plan.Approval + } } } inst, err := i.service.InstallAuthoritySolution(ctx, caller, row.orgID, row.declared.Target, row.declared.Role, row.ownerID, &approval) @@ -267,3 +277,15 @@ func (i *Installations) ConvergeAuthority(ctx context.Context) error { } return errors.Join(failures...) } + +// attemptContinues reports whether the core holds installation id pending or +// active: the states in which a stored plan's approval still names an +// operation the core finishes or replays. A revoked or absent record does not. +func attemptContinues(all []domain.Installation, id string) bool { + for _, inst := range all { + if inst.ID == id { + return inst.Status == domain.Pending || inst.Status == domain.Active + } + } + return false +} diff --git a/module/services/accounts/code/fixtures/installations_test.go b/module/services/accounts/code/fixtures/installations_test.go new file mode 100644 index 000000000..cf89cc011 --- /dev/null +++ b/module/services/accounts/code/fixtures/installations_test.go @@ -0,0 +1,31 @@ +package fixtures + +import ( + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" +) + +// A stored approval is reused while the core holds the installation pending +// or active (a replay of the same operation is idempotent, and a fresh +// operation id against an active record is refused by the host's plan +// guard). After an uninstall the receipt is refused for good, and a fixture +// that kept presenting it never installed again. +func TestAStoredApprovalContinuesAPendingOrActiveAttempt(t *testing.T) { + for name, tc := range map[string]struct { + all []domain.Installation + want bool + }{ + "pending": {all: []domain.Installation{{ID: "i", Status: domain.Pending}}, want: true}, + "active": {all: []domain.Installation{{ID: "i", Status: domain.Active}}, want: true}, + "revoked": {all: []domain.Installation{{ID: "i", Status: domain.Revoked}}, want: false}, + "another's": {all: []domain.Installation{{ID: "other", Status: domain.Pending}}, want: false}, + "not installed": {want: false}, + } { + t.Run(name, func(t *testing.T) { + if got := attemptContinues(tc.all, "i"); got != tc.want { + t.Fatalf("attemptContinues = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index ba96b70f4..985e4c507 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -7,11 +7,11 @@ require ( buf.build/go/protovalidate v1.4.0 connectrpc.com/connect v1.21.1 connectrpc.com/otelconnect v0.9.0 - github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261030222015-dea37afac9ad - github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad - github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad - github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261030222015-dea37afac9ad - github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261030222015-dea37afac9ad + github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261104040014-fab9ea74a58b + github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261104040014-fab9ea74a58b + github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261104040014-fab9ea74a58b + github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261104040014-fab9ea74a58b + github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261104040014-fab9ea74a58b github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc github.com/codefly-dev/core v0.19.17-0.20261029043538-2aa2e9aaf950 github.com/codefly-dev/interface-cache/go/cache v0.4.1 diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 9788b4693..dd058c4c5 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -56,18 +56,18 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261030222015-dea37afac9ad h1:fFpOC3bErcM55ouVmz4B+ehyhP6jDQtYBWI2sVsLiG0= -github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261030222015-dea37afac9ad/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= -github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad h1:evJ/d2Zi4nBXVc2NvMmWPphR7DN1cABwsfcy5LHjNjw= -github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:Cdk+10ebRh0tZA9ze2dDIrfQC34E+Y7LMi6UjSQ2dQU= -github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad h1:DZt68NLXwaGYWXo1RUM0+JrvuXt/za/EyeHMGYFSix8= -github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:or3dd9bqacXWMjke/n81l/QxRaLwq2lq/hL4V2qtAZo= -github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261030222015-dea37afac9ad h1:gq8WatMLdoqsfwafxj2ihYSl0jIEw/d7ZMTGRWu9qpo= -github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= +github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261104040014-fab9ea74a58b h1:68rIZ6V5GPFyyIr0bhaCdTjiiap1+ObTiVUNEdwLuD4= +github.com/codefly-dev/authority/libraries/authority-client/go v0.1.1-0.20261104040014-fab9ea74a58b/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261104040014-fab9ea74a58b h1:1VKjvHbrskPXeiM05+bXzncU4B/F08TbDiiHQlEoAOQ= +github.com/codefly-dev/authority/libraries/authority-core/go v0.0.0-20261104040014-fab9ea74a58b/go.mod h1:Cdk+10ebRh0tZA9ze2dDIrfQC34E+Y7LMi6UjSQ2dQU= +github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261104040014-fab9ea74a58b h1:AmncS7mb1ZstFYXq0uoQ2MWKUjOJN8kjwIl6N8YJV5o= +github.com/codefly-dev/authority/libraries/conformance/go v0.0.0-20261104040014-fab9ea74a58b/go.mod h1:or3dd9bqacXWMjke/n81l/QxRaLwq2lq/hL4V2qtAZo= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261104040014-fab9ea74a58b h1:VqGBJ+ILqFnin9fmjAl2gOopWGUhu+zuURppLdWxeOA= +github.com/codefly-dev/authority/libraries/delivery/go v0.0.0-20261104040014-fab9ea74a58b/go.mod h1:XsOFnO2MQdr37vM3ULa0Uwi8ZWptyVx7hRSUyIdaZ1Q= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027154629-b57068f61a0f h1:llYwKOBeCB6kS4faxICrR5YYlwnhiV+0qXjuT3IwBd4= github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027154629-b57068f61a0f/go.mod h1:/SZab4/ll1nAKTucP3h30rpcXo56BHmJWLkvs2ayaMs= -github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261030222015-dea37afac9ad h1:ZuYv+9r6U08xLQ5c+AOSBzhkF7h3lqUj6kMa0S2OKKE= -github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261030222015-dea37afac9ad/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261104040014-fab9ea74a58b h1:rGi94JiLGtCZUmJtwRaTkmvSHjDmIgJUAxTFSiVAW0I= +github.com/codefly-dev/authority/libraries/resources/go v0.0.0-20261104040014-fab9ea74a58b/go.mod h1:1rmjrVyr0h7zOuwOHS9i+BoeS9dr3XNvtupLZFwY6As= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc h1:olDnVJ1A1Retn0z9A59YMwDRmDLgkV2y1lbl+HzNrj8= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc/go.mod h1:VWPYY3FH4te7SzdN2xl9odNpWidJ1Grl/Hjs1SNoEF8= github.com/codefly-dev/core v0.19.17-0.20261029043538-2aa2e9aaf950 h1:U5HeCDUfftwVFbMYN1HGPUL+9HO+ske3nkExu1C2kYY= diff --git a/module/services/accounts/code/pkg/business/role_vocabularies.go b/module/services/accounts/code/pkg/business/role_vocabularies.go new file mode 100644 index 000000000..7c7e488a8 --- /dev/null +++ b/module/services/accounts/code/pkg/business/role_vocabularies.go @@ -0,0 +1,134 @@ +package business + +import ( + "slices" + "strings" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" +) + +// RoleVocabulary is one of the three sets of role words on this host. Two of +// them share words (editor, viewer) and none stands in for another, so each +// says what it decides and where its words are declared. The matrix renders +// this table; nothing types the words a second time. +type RoleVocabulary struct { + Name string + // Words are read from the code that defines each vocabulary, never typed + // here: the organisation roles from the OrgRole enum, the catalogue + // built-ins from the roles the permission vocabulary grants, the + // installation roles from the shared authority core's declaration rule. + Words []string + Decides string + DeclaredIn string +} + +// RoleVocabularies is the one table of the three role vocabularies, in the +// order a reader meets them: who administers the organisation, what a role +// assignment carries on this host's own resources, and what a person holds +// through one installation of a solution or a module page. +func RoleVocabularies() []RoleVocabulary { + return []RoleVocabulary{ + { + Name: "Organisation role", + Words: organizationRoleWords(), + Decides: "Who administers the organisation. The `org_admin` tier admits `owner` and `admin` (and a platform `super_admin`); " + + "`org_member` admits any member. The `/admin` pages gate on `roles:write`, which admits the same people " + + "(`owner`, `admin`, a platform `super_admin`) as the API's org-admin check.", + DeclaredIn: "the organisation membership; the session's `or` claim", + }, + { + Name: "Catalogue built-in role", + Words: catalogueBuiltInRoleWords(), + Decides: "Which `:` permissions (the Permission / scope column above) a role assignment carries " + + "on this host's own resources.", + DeclaredIn: "`servicePermissionVocabulary` in `pkg/business/service_vocabulary.go`", + }, + { + Name: "Installation role", + Words: installationRoleWords(), + Decides: "What a person holds through one installation of a solution or of a module page that declares roles: " + + "its page, its navigation entry, its Setup tab, and what a Work Context at its audience carries. " + + "The shared authority core requires every solution, and every module page that declares roles, to declare " + + "`installer`; `editor` and `viewer` are the standard an open handbook proposal sets, checked by nothing yet. " + + "The installation's owner holds `installer`.", + DeclaredIn: "the signed authority document of the solution or module page; granted per installation to a person or a team", + }, + } +} + +// organizationRoleWords are the OrgRole enum's names, highest first, as the +// session's `or` claim and the authorisation tiers spell them. +func organizationRoleWords() []string { + var words []string + for value := range gen.OrgRole_name { + role := gen.OrgRole(value) + if role == gen.OrgRole_ORG_ROLE_UNSPECIFIED { + continue + } + words = append(words, strings.ToLower(strings.TrimPrefix(role.String(), "ORG_ROLE_"))) + } + slices.SortFunc(words, func(a, b string) int { + return int(gen.OrgRole_value["ORG_ROLE_"+strings.ToUpper(b)]) - int(gen.OrgRole_value["ORG_ROLE_"+strings.ToUpper(a)]) + }) + return words +} + +// catalogueBuiltInRoleWords are the built-in roles the permission vocabulary +// grants at least one permission to, in the order the vocabulary first names +// them. A platform role that reaches a permission through the `*:*` root is +// not a catalogue role and is left out. +func catalogueBuiltInRoleWords() []string { + var words []string + for _, definition := range servicePermissionVocabulary { + for _, holder := range definition.BuiltInRoles { + name, _, _ := strings.Cut(holder, " (") + if strings.HasPrefix(name, "platform ") || slices.Contains(words, name) { + continue + } + words = append(words, name) + } + } + return words +} + +// installationRoleWords are the roles the shared authority core requires an +// installation target to declare: the installer alone, today; whatever else +// the target declares is its own. +func installationRoleWords() []string { + return []string{domain.InstallerRole} +} + +// renderRoleVocabularies is the matrix's "Three vocabularies" section, rendered +// from RoleVocabularies. +func renderRoleVocabularies() string { + var b strings.Builder + b.WriteString("\n## Three vocabularies\n\n") + b.WriteString("Three sets of role words appear on this host. They answer different questions,\n") + b.WriteString("and a word in one never means the same-spelled word in another.\n\n") + b.WriteString("| Vocabulary | Words | What it decides | Declared in |\n|---|---|---|---|\n") + for _, v := range RoleVocabularies() { + words := make([]string, 0, len(v.Words)) + for _, w := range v.Words { + words = append(words, "`"+w+"`") + } + spelled := strings.Join(words, ", ") + if v.Name == "Installation role" { + spelled += ", and whatever else a solution or a module page declares (the proposed standard adds `editor`, `viewer`)" + } + b.WriteString("| " + v.Name + " | " + spelled + " | " + escapeCell(v.Decides) + " | " + v.DeclaredIn + " |\n") + } + b.WriteString(` +The ` + "`/s/`" + ` page, the solution navigation and the Setup tab speak only +installation roles. A module page (a module whose signed manifest declares a +frontend) may declare installation roles too: then they gate its page, its +navigation entry and its audience exactly as a solution's, and the +organisation's roles no longer apply there. A module that declares no roles, +which every module without a page is, keeps the organisation's roles at its +audience, and its page is reached through the organisation's installation of +it and a team grant at that installation. The shared authority core refuses +roles on a module without a page. +`) + return b.String() +} diff --git a/module/services/accounts/code/pkg/business/role_vocabularies_test.go b/module/services/accounts/code/pkg/business/role_vocabularies_test.go new file mode 100644 index 000000000..e62e74afe --- /dev/null +++ b/module/services/accounts/code/pkg/business/role_vocabularies_test.go @@ -0,0 +1,58 @@ +package business + +import ( + "strings" + "testing" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/stretchr/testify/require" +) + +// The three vocabularies are read from the code that defines each, never +// typed: every word names something the source declares, and nothing the +// source declares is missing from the table. +func TestRoleVocabulariesAreReadFromTheirSources(t *testing.T) { + byName := map[string]RoleVocabulary{} + for _, v := range RoleVocabularies() { + byName[v.Name] = v + } + require.Len(t, byName, 3) + + organisation := byName["Organisation role"].Words + require.Equal(t, []string{"owner", "admin", "member"}, organisation) + for _, word := range organisation { + _, declared := gen.OrgRole_value["ORG_ROLE_"+strings.ToUpper(word)] + require.True(t, declared, "%q is not an OrgRole", word) + } + require.Len(t, organisation, len(gen.OrgRole_name)-1, "every OrgRole but UNSPECIFIED is a word") + + catalogue := byName["Catalogue built-in role"].Words + require.Equal(t, []string{"admin", "editor", "viewer"}, catalogue) + for _, word := range catalogue { + granted := false + for _, definition := range servicePermissionVocabulary { + for _, holder := range definition.BuiltInRoles { + if name, _, _ := strings.Cut(holder, " ("); name == word { + granted = true + } + } + } + require.True(t, granted, "%q holds no permission in the vocabulary", word) + } + for _, v := range RoleVocabularies() { + require.NotContains(t, v.Words, "super_admin", "a platform role is none of the three vocabularies") + } + + require.Equal(t, []string{domain.InstallerRole}, byName["Installation role"].Words) + + rendered := renderRoleVocabularies() + for _, v := range RoleVocabularies() { + require.Contains(t, rendered, "| "+v.Name+" | ") + for _, word := range v.Words { + require.Contains(t, rendered, "`"+word+"`") + } + } + require.Contains(t, string(RenderRPCPolicyMatrix()), rendered) +} diff --git a/module/services/accounts/code/pkg/business/rpc_policy_matrix.go b/module/services/accounts/code/pkg/business/rpc_policy_matrix.go index f9dcf5bff..cfccef165 100644 --- a/module/services/accounts/code/pkg/business/rpc_policy_matrix.go +++ b/module/services/accounts/code/pkg/business/rpc_policy_matrix.go @@ -57,7 +57,7 @@ func RenderRPCPolicyMatrix() []byte { for _, tier := range tiers { fmt.Fprintf(&b, "- `%s`: %d\n", tier, counts[RPCPolicyTier(tier)]) } - b.WriteString(threeVocabularies) + b.WriteString(renderRoleVocabularies()) return []byte(b.String()) } @@ -161,29 +161,3 @@ func serviceCount(policies []RPCPolicy) int { func escapeCell(value string) string { return strings.ReplaceAll(value, "|", "\\|") } - -// threeVocabularies names the three sets of role words a reader of this matrix -// meets, because two of them share words (editor, viewer) and none stands in -// for another. -const threeVocabularies = ` -## Three vocabularies - -Three sets of role words appear on this host. They answer different questions, -and a word in one never means the same-spelled word in another. - -| Vocabulary | Words | What it decides | Declared in | -|---|---|---|---| -| Organisation role | ` + "`owner`, `admin`, `member`" + ` | Who administers the organisation. The ` + "`org_admin`" + ` tier admits ` + "`owner`" + ` and ` + "`admin`" + ` (and a platform ` + "`super_admin`" + `); ` + "`org_member`" + ` admits any member. The ` + "`/admin`" + ` pages gate on ` + "`roles:write`" + `, which admits the same people (` + "`owner`, `admin`" + `, a platform ` + "`super_admin`" + `) as the API's org-admin check. | the organisation membership; the session's ` + "`or`" + ` claim | -| Catalogue built-in role | ` + "`admin`, `editor`, `viewer`" + ` | Which ` + "`:`" + ` permissions (the Permission / scope column above) a role assignment carries on this host's own resources. | ` + "`servicePermissionVocabulary`" + ` in ` + "`pkg/business/service_vocabulary.go`" + ` | -| Installation role | ` + "`installer`" + `, and whatever else a solution or a module page declares (the proposed standard adds ` + "`editor`, `viewer`" + `) | What a person holds through one installation of a solution or of a module page that declares roles: its page, its navigation entry, its Setup tab, and what a Work Context at its audience carries. The shared authority core requires every solution, and every module page that declares roles, to declare ` + "`installer`" + `; ` + "`editor`" + ` and ` + "`viewer`" + ` are the standard an open handbook proposal sets, checked by nothing yet. The installation's owner holds ` + "`installer`" + `. | the signed authority document of the solution or module page; granted per installation to a person or a team | - -The ` + "`/s/`" + ` page, the solution navigation and the Setup tab speak only -installation roles. A module page (a module whose signed manifest declares a -frontend) may declare installation roles too: then they gate its page, its -navigation entry and its audience exactly as a solution's, and the -organisation's roles no longer apply there. A module that declares no roles, -which every module without a page is, keeps the organisation's roles at its -audience, and its page is reached through the organisation's installation of -it and a team grant at that installation. The shared authority core refuses -roles on a module without a page. -`