roles
This commit is contained in:
parent
6319da650c
commit
3fdc8cb489
26 changed files with 2245 additions and 488 deletions
|
|
@ -1,175 +1,132 @@
|
|||
// Package role defines the tenant membership roles and the single permission
|
||||
// matrix that authorizes console and management behavior.
|
||||
// Package role defines the permission vocabulary 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.
|
||||
// Roles are rows in the roles table and their permissions live in
|
||||
// role_permissions, so a tenant can define its own and authorization is
|
||||
// resolved by the database rather than from a matrix held here. What belongs in
|
||||
// this package is the closed set of permissions a role may be granted, plus the
|
||||
// labels the console renders.
|
||||
//
|
||||
// Keep the permission list in step with the role_permissions_permission_check
|
||||
// constraint in migration 0006: a name that is not in both places cannot be
|
||||
// stored.
|
||||
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"
|
||||
// ReadConsole opens the management console. Self-service members lack it so
|
||||
// that they are routed to their profile instead.
|
||||
ReadConsole Permission = "console.read"
|
||||
// ReadUsers lists a tenant's members and their roles.
|
||||
ReadUsers Permission = "users.read"
|
||||
// ManageApplications registers applications and configures their client
|
||||
// credentials.
|
||||
ManageApplications Permission = "applications.manage"
|
||||
ManageKeys Permission = "keys.manage"
|
||||
ManageContracts Permission = "contracts.manage"
|
||||
ManageUsers Permission = "users.manage"
|
||||
ManageTenants Permission = "tenants.manage"
|
||||
EditProfile Permission = "profile.edit"
|
||||
// ManageKeys issues and revokes API keys.
|
||||
ManageKeys Permission = "keys.manage"
|
||||
// ManageContracts imports OpenAPI contracts.
|
||||
ManageContracts Permission = "contracts.manage"
|
||||
// ManageUsers invites members and changes the roles and application grants
|
||||
// of existing ones.
|
||||
ManageUsers Permission = "users.manage"
|
||||
// ManageRoles creates and edits the tenant's own roles. Kept separate from
|
||||
// ManageUsers because defining roles is what defines privilege.
|
||||
ManageRoles Permission = "roles.manage"
|
||||
// ManageTenants creates tenants and administers the installation.
|
||||
ManageTenants Permission = "tenants.manage"
|
||||
// EditProfile changes one's own display name and picture.
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
// ordered lists every permission from most to least sensitive. The order is the
|
||||
// one the console renders checkboxes in.
|
||||
var ordered = []Permission{
|
||||
ReadConsole, ReadUsers, ManageApplications, ManageKeys,
|
||||
ManageContracts, ManageUsers, ManageTenants, EditProfile,
|
||||
ManageTenants,
|
||||
ManageRoles,
|
||||
ManageUsers,
|
||||
ManageApplications,
|
||||
ManageKeys,
|
||||
ManageContracts,
|
||||
ReadUsers,
|
||||
ReadConsole,
|
||||
EditProfile,
|
||||
}
|
||||
|
||||
// Entry describes a role for the console and for API clients.
|
||||
var known = map[Permission]bool{
|
||||
ReadConsole: true,
|
||||
ReadUsers: true,
|
||||
ManageApplications: true,
|
||||
ManageKeys: true,
|
||||
ManageContracts: true,
|
||||
ManageUsers: true,
|
||||
ManageRoles: true,
|
||||
ManageTenants: true,
|
||||
EditProfile: true,
|
||||
}
|
||||
|
||||
// Entry describes a permission for the console and for API clients.
|
||||
type Entry struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var labels = map[Role]string{
|
||||
Owner: "Owner",
|
||||
Admin: "Administrator",
|
||||
Developer: "Developer",
|
||||
Operator: "Operator",
|
||||
Viewer: "Viewer",
|
||||
User: "User",
|
||||
var details = map[Permission]struct{ label, description string }{
|
||||
ReadConsole: {"Read console", "Open the management console and pick tenants."},
|
||||
ReadUsers: {"Read members", "List the members of a tenant and the roles they hold."},
|
||||
ManageApplications: {"Manage applications", "Register applications and configure client credentials."},
|
||||
ManageKeys: {"Manage API keys", "Issue and revoke API keys."},
|
||||
ManageContracts: {"Manage contracts", "Import OpenAPI contracts and their endpoints."},
|
||||
ManageUsers: {"Manage members", "Invite members and change their roles and application grants."},
|
||||
ManageRoles: {"Manage roles", "Create and edit the roles of this tenant."},
|
||||
ManageTenants: {"Manage tenants", "Create tenants and administer the installation."},
|
||||
EditProfile: {"Edit own profile", "Change one's own display name and picture."},
|
||||
}
|
||||
|
||||
// All returns every role ordered from most to least privileged.
|
||||
func All() []Role {
|
||||
result := make([]Role, len(catalog))
|
||||
copy(result, catalog)
|
||||
// Permissions returns every permission in display order.
|
||||
func Permissions() []Permission {
|
||||
result := make([]Permission, len(ordered))
|
||||
copy(result, ordered)
|
||||
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 {
|
||||
// Parse validates a submitted permission name. Unknown values are rejected so
|
||||
// that a crafted payload cannot invent capabilities.
|
||||
func Parse(value string) (Permission, bool) {
|
||||
candidate := Permission(value)
|
||||
if !known[candidate] {
|
||||
return "", false
|
||||
}
|
||||
return candidate, true
|
||||
}
|
||||
|
||||
// Valid reports whether the value names a role.
|
||||
func (r Role) Valid() bool {
|
||||
_, known := grants[r]
|
||||
return known
|
||||
// Valid reports whether the value names a permission.
|
||||
func (p Permission) Valid() bool {
|
||||
return known[p]
|
||||
}
|
||||
|
||||
// Allows reports whether the role grants the permission.
|
||||
func (r Role) Allows(permission Permission) bool {
|
||||
return grants[r][permission]
|
||||
// Catalog returns every permission with a label and description so that the
|
||||
// console and API clients never hardcode the list.
|
||||
func Catalog() []Entry {
|
||||
result := make([]Entry, 0, len(ordered))
|
||||
for _, permission := range ordered {
|
||||
detail := details[permission]
|
||||
result = append(result, Entry{
|
||||
Value: string(permission),
|
||||
Label: detail.label,
|
||||
Description: detail.description,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Permissions returns the role's permissions in a stable order.
|
||||
func (r Role) Permissions() []string {
|
||||
// Values returns every permission as strings, for use as a SQL array parameter.
|
||||
func Values() []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)
|
||||
result = append(result, string(permission))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,130 +6,112 @@ import (
|
|||
"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},
|
||||
func TestParseRejectsUnknownPermissions(t *testing.T) {
|
||||
valid := []string{
|
||||
"console.read", "users.read", "applications.manage", "keys.manage",
|
||||
"contracts.manage", "users.manage", "roles.manage", "tenants.manage",
|
||||
"profile.edit",
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
for _, value := range valid {
|
||||
if _, ok := Parse(value); !ok {
|
||||
t.Errorf("Parse(%q) = not ok, want a permission", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
for _, value := range []string{"", "Console.read", "console", "superuser", "users.manage; DROP TABLE roles"} {
|
||||
if parsed, ok := Parse(value); ok {
|
||||
t.Errorf("Parse(%q) = %q, want rejection", value, parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 TestValidMatchesParse(t *testing.T) {
|
||||
for _, permission := range Permissions() {
|
||||
if !permission.Valid() {
|
||||
t.Errorf("%q reports itself invalid", permission)
|
||||
}
|
||||
if parsed, ok := Parse(string(permission)); !ok || parsed != permission {
|
||||
t.Errorf("Parse(%q) did not round-trip", permission)
|
||||
}
|
||||
}
|
||||
if Permission("nope").Valid() {
|
||||
t.Error("an unknown permission reports itself valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionsAreDottedNames(t *testing.T) {
|
||||
for _, permission := range Permissions() {
|
||||
name := string(permission)
|
||||
if len(name) < 3 || strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
|
||||
t.Fatalf("%q is not a plausible permission name", name)
|
||||
}
|
||||
dot := false
|
||||
for _, character := range name {
|
||||
switch {
|
||||
case character == '.':
|
||||
dot = true
|
||||
case character >= 'a' && character <= 'z', character >= '0' && character <= '9', character == '-':
|
||||
default:
|
||||
t.Fatalf("%q contains %q; permissions use lowercase words", name, character)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if !dot {
|
||||
t.Errorf("%q should be a namespaced name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantableExcludesOwner(t *testing.T) {
|
||||
for _, item := range Grantable() {
|
||||
if item == Owner {
|
||||
t.Fatal("owner must not be grantable through an invitation")
|
||||
}
|
||||
func TestCatalogDescribesEveryPermission(t *testing.T) {
|
||||
catalog := Catalog()
|
||||
if len(catalog) != len(Permissions()) {
|
||||
t.Fatalf("catalog has %d entries, want %d", len(catalog), len(Permissions()))
|
||||
}
|
||||
if len(GrantableValues()) != len(catalog)-1 {
|
||||
t.Fatalf("GrantableValues() length = %d", len(GrantableValues()))
|
||||
seen := make(map[string]bool, len(catalog))
|
||||
for _, entry := range catalog {
|
||||
if entry.Value == "" || entry.Label == "" || entry.Description == "" {
|
||||
t.Errorf("incomplete catalog entry %+v", entry)
|
||||
}
|
||||
if seen[entry.Value] {
|
||||
t.Errorf("permission %q appears twice in the catalog", entry.Value)
|
||||
}
|
||||
seen[entry.Value] = true
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// TestCatalogFollowsDisplayOrder guards the order the console renders the
|
||||
// permission checkboxes in: the most sensitive capability first.
|
||||
func TestCatalogFollowsDisplayOrder(t *testing.T) {
|
||||
catalog := Catalog()
|
||||
want := []string{"tenants.manage", "roles.manage", "users.manage"}
|
||||
for index, value := range want {
|
||||
if catalog[index].Value != value {
|
||||
t.Errorf("catalog[%d] = %q, want %q", index, catalog[index].Value, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionsReturnsACopy(t *testing.T) {
|
||||
first := Permissions()
|
||||
if len(first) == 0 {
|
||||
t.Fatal("no permissions")
|
||||
}
|
||||
first[0] = "mutated"
|
||||
if Permissions()[0] == "mutated" {
|
||||
t.Error("Permissions exposed its backing array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValuesMatchesPermissions(t *testing.T) {
|
||||
values := Values()
|
||||
permissions := Permissions()
|
||||
if len(values) != len(permissions) {
|
||||
t.Fatalf("Values has %d entries, want %d", len(values), len(permissions))
|
||||
}
|
||||
for index, permission := range permissions {
|
||||
if values[index] != string(permission) {
|
||||
t.Errorf("Values()[%d] = %q, want %q", index, values[index], permission)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(values, string(ManageUsers)) {
|
||||
t.Error("users.manage is missing from Values")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue