fix installer

This commit is contained in:
Daniel Noulet 2026-10-04 12:24:13 +02:00
parent 318fb51085
commit 6319da650c
31 changed files with 2391 additions and 87 deletions

175
internal/role/role.go Normal file
View file

@ -0,0 +1,175 @@
// Package role defines the tenant membership roles and the single permission
// matrix that authorizes console and management behavior.
//
// Roles are stored in memberships.role. Authorization decisions must derive
// from Grants or Allows so that SQL role checks and Go checks cannot drift
// apart.
package role
// Role is a tenant membership role.
type Role string
const (
// Owner has full control of a tenant, including tenant-level settings.
Owner Role = "owner"
// Admin manages applications, credentials, contracts, and members.
Admin Role = "admin"
// Developer manages applications, credentials, and contracts.
Developer Role = "developer"
// Operator issues and revokes credentials and imports contracts.
Operator Role = "operator"
// Viewer reads the console without changing anything.
Viewer Role = "viewer"
// User is a self-service member. It carries no console access and exists so
// that end users of client applications still hold a tenant role that
// applications can read through /connect/userinfo.
User Role = "user"
)
// Permission is a capability granted by a role.
type Permission string
const (
ReadConsole Permission = "console.read"
ReadUsers Permission = "users.read"
ManageApplications Permission = "applications.manage"
ManageKeys Permission = "keys.manage"
ManageContracts Permission = "contracts.manage"
ManageUsers Permission = "users.manage"
ManageTenants Permission = "tenants.manage"
EditProfile Permission = "profile.edit"
)
// catalog lists every role in descending order of privilege.
var catalog = []Role{Owner, Admin, Developer, Operator, Viewer, User}
// grants is the permission matrix. ReadConsole is deliberately absent for User
// so that self-service members are routed to the profile screen instead of the
// management console.
var grants = map[Role]map[Permission]bool{
Owner: {
ReadConsole: true, ReadUsers: true, ManageApplications: true, ManageKeys: true,
ManageContracts: true, ManageUsers: true, ManageTenants: true, EditProfile: true,
},
Admin: {
ReadConsole: true, ReadUsers: true, ManageApplications: true, ManageKeys: true,
ManageContracts: true, ManageUsers: true, EditProfile: true,
},
Developer: {
ReadConsole: true, ReadUsers: true, ManageApplications: true, ManageKeys: true,
ManageContracts: true, EditProfile: true,
},
Operator: {
ReadConsole: true, ReadUsers: true, ManageKeys: true, ManageContracts: true, EditProfile: true,
},
Viewer: {
ReadConsole: true, ReadUsers: true, EditProfile: true,
},
User: {
EditProfile: true,
},
}
var ordered = []Permission{
ReadConsole, ReadUsers, ManageApplications, ManageKeys,
ManageContracts, ManageUsers, ManageTenants, EditProfile,
}
// Entry describes a role for the console and for API clients.
type Entry struct {
Value string `json:"value"`
Label string `json:"label"`
Permissions []string `json:"permissions"`
}
var labels = map[Role]string{
Owner: "Owner",
Admin: "Administrator",
Developer: "Developer",
Operator: "Operator",
Viewer: "Viewer",
User: "User",
}
// All returns every role ordered from most to least privileged.
func All() []Role {
result := make([]Role, len(catalog))
copy(result, catalog)
return result
}
// Catalog returns every role with its label and permissions so that the console
// and API clients never hardcode the role list.
func Catalog() []Entry {
result := make([]Entry, 0, len(catalog))
for _, item := range catalog {
result = append(result, Entry{Value: string(item), Label: labels[item], Permissions: item.Permissions()})
}
return result
}
// Parse converts a stored or submitted value into a Role.
func Parse(value string) (Role, bool) {
candidate := Role(value)
if _, known := grants[candidate]; !known {
return "", false
}
return candidate, true
}
// Valid reports whether the value names a role.
func (r Role) Valid() bool {
_, known := grants[r]
return known
}
// Allows reports whether the role grants the permission.
func (r Role) Allows(permission Permission) bool {
return grants[r][permission]
}
// Permissions returns the role's permissions in a stable order.
func (r Role) Permissions() []string {
result := make([]string, 0, len(ordered))
for _, permission := range ordered {
if r.Allows(permission) {
result = append(result, string(permission))
}
}
return result
}
// Grants returns the roles that grant a permission, for use as a SQL parameter
// so that role checks in queries stay aligned with this matrix.
func Grants(permission Permission) []string {
result := make([]string, 0, len(catalog))
for _, item := range catalog {
if item.Allows(permission) {
result = append(result, string(item))
}
}
return result
}
// Grantable returns the roles a tenant administrator may hand to a new member.
// Ownership is not grantable during an invitation because it is established by
// creating or claiming a tenant.
func Grantable() []Role {
result := make([]Role, 0, len(catalog)-1)
for _, item := range catalog {
if item != Owner {
result = append(result, item)
}
}
return result
}
// GrantableValues returns Grantable as strings for a request payload.
func GrantableValues() []string {
values := Grantable()
result := make([]string, len(values))
for index, item := range values {
result[index] = string(item)
}
return result
}

135
internal/role/role_test.go Normal file
View file

@ -0,0 +1,135 @@
package role
import (
"slices"
"strings"
"testing"
)
func TestParseRejectsUnknownRoles(t *testing.T) {
tests := []struct {
value string
want bool
}{
{value: "owner", want: true},
{value: "admin", want: true},
{value: "developer", want: true},
{value: "operator", want: true},
{value: "viewer", want: true},
{value: "user", want: true},
{value: "Owner", want: false},
{value: "", want: false},
{value: "superuser", want: false},
{value: "owner; DROP TABLE memberships", want: false},
}
for _, test := range tests {
t.Run(test.value, func(t *testing.T) {
_, ok := Parse(test.value)
if ok != test.want {
t.Fatalf("Parse(%q) ok = %v, want %v", test.value, ok, test.want)
}
})
}
}
func TestPermissionMatrix(t *testing.T) {
tests := []struct {
role Role
permission Permission
want bool
}{
{role: Owner, permission: ManageTenants, want: true},
{role: Owner, permission: ManageUsers, want: true},
{role: Admin, permission: ManageTenants, want: false},
{role: Admin, permission: ManageUsers, want: true},
{role: Admin, permission: ManageApplications, want: true},
{role: Developer, permission: ManageUsers, want: false},
{role: Developer, permission: ManageApplications, want: true},
{role: Developer, permission: ManageKeys, want: true},
{role: Developer, permission: ManageContracts, want: true},
{role: Operator, permission: ManageApplications, want: false},
{role: Operator, permission: ManageKeys, want: true},
{role: Operator, permission: ManageContracts, want: true},
{role: Viewer, permission: ManageKeys, want: false},
{role: Viewer, permission: ReadConsole, want: true},
{role: User, permission: ReadConsole, want: false},
{role: User, permission: ReadUsers, want: false},
{role: User, permission: EditProfile, want: true},
}
for _, test := range tests {
t.Run(string(test.role)+"/"+string(test.permission), func(t *testing.T) {
if got := test.role.Allows(test.permission); got != test.want {
t.Fatalf("Allows(%q) = %v, want %v", test.permission, got, test.want)
}
})
}
}
func TestEveryRoleCanEditItsOwnProfile(t *testing.T) {
for _, item := range All() {
if !item.Allows(EditProfile) {
t.Fatalf("role %q cannot edit its own profile", item)
}
}
}
func TestGrantsMatchAllows(t *testing.T) {
for _, permission := range ordered {
granted := Grants(permission)
for _, item := range All() {
if slices.Contains(granted, string(item)) != item.Allows(permission) {
t.Fatalf("Grants(%q) and Allows disagree for role %q", permission, item)
}
}
}
}
func TestGrantsListsRolesInPrivilegeOrder(t *testing.T) {
if got := Grants(ManageKeys); !slices.Equal(got, []string{"owner", "admin", "developer", "operator"}) {
t.Fatalf("Grants(ManageKeys) = %v", got)
}
if got := Grants(ManageUsers); !slices.Equal(got, []string{"owner", "admin"}) {
t.Fatalf("Grants(ManageUsers) = %v", got)
}
if got := Grants(ReadConsole); !slices.Contains(got, string(Owner)) || slices.Contains(got, string(User)) {
t.Fatalf("Grants(ReadConsole) = %v", got)
}
}
func TestCatalogCoversEveryRoleWithALabel(t *testing.T) {
entries := Catalog()
if len(entries) != len(catalog) {
t.Fatalf("Catalog() length = %d, want %d", len(entries), len(catalog))
}
for _, entry := range entries {
if entry.Label == "" {
t.Fatalf("role %q has no label", entry.Value)
}
parsed, ok := Parse(entry.Value)
if !ok {
t.Fatalf("catalog entry %q is not a known role", entry.Value)
}
if len(entry.Permissions) != len(parsed.Permissions()) {
t.Fatalf("role %q catalog permissions drifted from the matrix", entry.Value)
}
}
}
func TestGrantableExcludesOwner(t *testing.T) {
for _, item := range Grantable() {
if item == Owner {
t.Fatal("owner must not be grantable through an invitation")
}
}
if len(GrantableValues()) != len(catalog)-1 {
t.Fatalf("GrantableValues() length = %d", len(GrantableValues()))
}
}
func TestPermissionsUseDottedNames(t *testing.T) {
for _, permission := range All()[0].Permissions() {
if !strings.Contains(permission, ".") {
t.Fatalf("permission %q should be a dotted capability name", permission)
}
}
}