Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions module/services/accounts/code/fixtures/installations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
31 changes: 31 additions & 0 deletions module/services/accounts/code/fixtures/installations_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
10 changes: 5 additions & 5 deletions module/services/accounts/code/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions module/services/accounts/code/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
134 changes: 134 additions & 0 deletions module/services/accounts/code/pkg/business/role_vocabularies.go
Original file line number Diff line number Diff line change
@@ -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 `<resource>:<action>` 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/<id>`" + ` 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()
}
Original file line number Diff line number Diff line change
@@ -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)
}
28 changes: 1 addition & 27 deletions module/services/accounts/code/pkg/business/rpc_policy_matrix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down Expand Up @@ -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 ` + "`<resource>:<action>`" + ` 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/<id>`" + ` 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.
`
Loading