The behaviour suite in internal/behaviour installs the application into an empty database through the web installer, signs in, walks every flow, and then replaces the process to confirm the state was persisted rather than held in memory. It found four defects, each fixed here or in the preceding commit: - Migrate was not safe to run from two instances at once. CREATE TABLE IF NOT EXISTS is not race free in PostgreSQL and collided on the pg_type unique index, and two replicas could apply the same file. The run now holds a session-level advisory lock, so a rolling deployment waits instead of failing. - Bootstrap refused ownership whenever a tenant held any member, so installing with the demonstration seed and then setting a local administrator password produced an account that could never reach the console. It now grants ownership only while the tenant has no owner, which still never takes a tenant over from one that does. Three of the fixes are covered by store-level regression tests in the same commit: the ownership rule, the duplicate tenant slug conflict, and concurrent migration runs. The suite is skipped by go test ./... because it needs a scratch database and the Google credentials for the installer. Run it with make behaviour-test, which also resets the database afterwards. make test no longer inherits the database variables, so unit runs need nothing. Completes the earlier correction to the docs: the role matrix lives in the database rather than in internal/role, and a local administrator claims the system administrator role when no Google account has.
279 lines
11 KiB
Go
279 lines
11 KiB
Go
package behaviour
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// rolePhases exercises the database-driven role and permission model: the shared
|
|
// catalog, tenant-defined roles, and the rule that a role may never grant more
|
|
// than the administrator already holds.
|
|
func rolePhases(t *testing.T, j *journey) {
|
|
t.Helper()
|
|
host := j.adminHost()
|
|
|
|
phase(t, "the permission catalog lists the enforced capabilities", func(t *testing.T) {
|
|
type entry struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
System bool `json:"system"`
|
|
Enforced bool `json:"enforced"`
|
|
Description string `json:"description"`
|
|
}
|
|
type listing struct {
|
|
Permissions []entry `json:"permissions"`
|
|
}
|
|
result := decode[listing](t, j.admin.get(host, "/api/v1/permissions", nil), http.StatusOK)
|
|
names := map[string]entry{}
|
|
for _, item := range result.Permissions {
|
|
names[item.Name] = item
|
|
}
|
|
for _, want := range []string{
|
|
"console.read", "users.read", "users.manage", "applications.manage",
|
|
"keys.manage", "contracts.manage", "roles.manage", "tenants.manage", "profile.edit",
|
|
} {
|
|
entry, ok := names[want]
|
|
if !ok {
|
|
t.Errorf("permission %q is missing from the catalog", want)
|
|
continue
|
|
}
|
|
if !entry.System || !entry.Enforced {
|
|
t.Errorf("permission %q is not marked system and enforced: %+v", want, entry)
|
|
}
|
|
if entry.Label == "" || entry.Description == "" {
|
|
t.Errorf("permission %q has no label or description the console can render", want)
|
|
}
|
|
}
|
|
})
|
|
|
|
phase(t, "a malformed permission name is refused", func(t *testing.T) {
|
|
for _, name := range []string{"Reports Export", "reports/export", "reports..export", ""} {
|
|
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
|
|
"name": name, "label": "Reports export",
|
|
})
|
|
statusOnly(t, response, http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
phase(t, "a permission without a label is refused", func(t *testing.T) {
|
|
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
|
|
"name": "reports.export", "label": " ",
|
|
})
|
|
statusOnly(t, response, http.StatusBadRequest)
|
|
})
|
|
|
|
phase(t, "a tenant administrator may extend the catalog", func(t *testing.T) {
|
|
// Adding a name does not enforce anything; it only makes the name grantable.
|
|
j.permission = "reports.export"
|
|
type created struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
System bool `json:"system"`
|
|
Enforced bool `json:"enforced"`
|
|
}
|
|
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
|
|
"name": j.permission, "label": "Export reports", "description": "Download tenant reports",
|
|
})
|
|
permission := decode[created](t, response, http.StatusCreated)
|
|
if permission.Name != j.permission {
|
|
t.Errorf("name = %q", permission.Name)
|
|
}
|
|
if permission.System || permission.Enforced {
|
|
t.Errorf("a tenant permission must not be system or enforced: %+v", permission)
|
|
}
|
|
})
|
|
|
|
phase(t, "a duplicate permission name is refused", func(t *testing.T) {
|
|
response := j.admin.json(http.MethodPost, host, "/api/v1/permissions", map[string]any{
|
|
"name": j.permission, "label": "Export reports again",
|
|
})
|
|
statusOnly(t, response, http.StatusConflict)
|
|
})
|
|
|
|
phase(t, "a built-in permission cannot be deleted", func(t *testing.T) {
|
|
response := j.admin.request(http.MethodDelete, host, "/api/v1/permissions/console.read", nil,
|
|
map[string]string{"Origin": "http://" + host})
|
|
statusOnly(t, response, http.StatusConflict)
|
|
})
|
|
|
|
phase(t, "a tenant defines its own role", func(t *testing.T) {
|
|
type created struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
System bool `json:"system"`
|
|
Permissions []string `json:"permissions"`
|
|
MemberCount int `json:"member_count"`
|
|
}
|
|
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
|
"tenant_id": j.scopedTenantID, "slug": "reporter", "name": "Reporter",
|
|
"description": "Reads the console and exports reports",
|
|
"permissions": []string{"console.read", "users.read", j.permission},
|
|
})
|
|
role := decode[created](t, response, http.StatusCreated)
|
|
if role.Slug != "reporter" {
|
|
t.Errorf("slug = %q", role.Slug)
|
|
}
|
|
if role.System {
|
|
t.Error("a tenant role must not be marked as a system role")
|
|
}
|
|
if len(role.Permissions) != 3 {
|
|
t.Errorf("permissions = %v, want three", role.Permissions)
|
|
}
|
|
j.customRoleID = role.ID
|
|
})
|
|
|
|
phase(t, "a permission a role grants cannot be deleted", func(t *testing.T) {
|
|
response := j.admin.request(http.MethodDelete, host, "/api/v1/permissions/"+j.permission, nil,
|
|
map[string]string{"Origin": "http://" + host})
|
|
statusOnly(t, response, http.StatusConflict)
|
|
})
|
|
|
|
phase(t, "a duplicate role slug is refused", func(t *testing.T) {
|
|
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
|
"tenant_id": j.scopedTenantID, "slug": "reporter", "name": "Reporter again",
|
|
"permissions": []string{"console.read"},
|
|
})
|
|
statusOnly(t, response, http.StatusConflict)
|
|
})
|
|
|
|
phase(t, "a malformed role slug or name is refused", func(t *testing.T) {
|
|
for _, payload := range []map[string]any{
|
|
{"tenant_id": j.scopedTenantID, "slug": "Reporter", "name": "Upper"},
|
|
{"tenant_id": j.scopedTenantID, "slug": "reporter_2", "name": "Underscore"},
|
|
{"tenant_id": j.scopedTenantID, "slug": "reporter2", "name": ""},
|
|
} {
|
|
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", payload)
|
|
statusOnly(t, response, http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
phase(t, "a role naming an unknown permission is refused", func(t *testing.T) {
|
|
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
|
"tenant_id": j.scopedTenantID, "slug": "invented", "name": "Invented",
|
|
"permissions": []string{"not.a.real.permission"},
|
|
})
|
|
statusOnly(t, response, http.StatusBadRequest)
|
|
})
|
|
|
|
phase(t, "a role cannot grant a capability the caller does not hold", func(t *testing.T) {
|
|
// tenants.manage belongs to owners alone. A tenant administrator asking for a
|
|
// role that grants it must not be able to mint their way above their own
|
|
// authority, and must not even learn that the capability exists.
|
|
browser := j.browser()
|
|
token := csrfFrom(t, bodyOf(t, browser.get(host, "/login", nil)))
|
|
response := browser.form(host, "/auth/password", neturlValues(map[string]string{
|
|
"csrf_token": token, "email": "interview@nmbs.auth.dev", "password": "admin123",
|
|
}), nil)
|
|
if response.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("sign-in = %d: %s", response.StatusCode, bodyOf(t, response))
|
|
}
|
|
response = browser.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
|
"tenant_id": j.tenantID, "slug": "escalation", "name": "Escalation",
|
|
"permissions": []string{"tenants.manage"},
|
|
})
|
|
if response.StatusCode != http.StatusNotFound && response.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("escalation attempt = %d, want a refusal: %s", response.StatusCode, bodyOf(t, response))
|
|
}
|
|
})
|
|
|
|
phase(t, "a role's permissions and name can be changed", func(t *testing.T) {
|
|
type updated struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
response := j.admin.json(http.MethodPut, host, "/api/v1/roles/"+j.customRoleID, map[string]any{
|
|
"name": "Report reader", "description": "Now with fewer capabilities",
|
|
"permissions": []string{"console.read"},
|
|
})
|
|
role := decode[updated](t, response, http.StatusOK)
|
|
if role.Name != "Report reader" {
|
|
t.Errorf("name = %q", role.Name)
|
|
}
|
|
if len(role.Permissions) != 1 {
|
|
t.Errorf("permissions = %v, want just console.read", role.Permissions)
|
|
}
|
|
})
|
|
|
|
phase(t, "the permission is deletable once no role grants it", func(t *testing.T) {
|
|
// The update above left the custom permission unreferenced.
|
|
response := j.admin.request(http.MethodDelete, host, "/api/v1/permissions/"+j.permission, nil,
|
|
map[string]string{"Origin": "http://" + host})
|
|
if response.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("delete permission = %d: %s", response.StatusCode, bodyOf(t, response))
|
|
}
|
|
response.Body.Close()
|
|
})
|
|
|
|
phase(t, "a role still held by a member cannot be deleted", func(t *testing.T) {
|
|
// Give the custom role to the ungranted member, then try to remove it.
|
|
access := j.admin.json(http.MethodPut, host, "/api/v1/users/"+j.ungrantedMemberID+"/access", map[string]any{
|
|
"tenant_id": j.scopedTenantID, "role_id": j.customRoleID, "application_ids": []string{},
|
|
})
|
|
if access.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("assign custom role = %d: %s", access.StatusCode, bodyOf(t, access))
|
|
}
|
|
response := j.admin.request(http.MethodDelete, host, "/api/v1/roles/"+j.customRoleID, nil,
|
|
map[string]string{"Origin": "http://" + host})
|
|
statusOnly(t, response, http.StatusConflict)
|
|
})
|
|
|
|
phase(t, "an unused role is deleted, and its permission with it", func(t *testing.T) {
|
|
type created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
response := j.admin.json(http.MethodPost, host, "/api/v1/roles", map[string]any{
|
|
"tenant_id": j.scopedTenantID, "slug": "temporary", "name": "Temporary",
|
|
"permissions": []string{"console.read"},
|
|
})
|
|
temporary := decode[created](t, response, http.StatusCreated)
|
|
|
|
response = j.admin.request(http.MethodDelete, host, "/api/v1/roles/"+temporary.ID, nil,
|
|
map[string]string{"Origin": "http://" + host})
|
|
if response.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("delete role = %d: %s", response.StatusCode, bodyOf(t, response))
|
|
}
|
|
response.Body.Close()
|
|
|
|
// The custom permission is now unreferenced, so it can go too.
|
|
response.Body.Close()
|
|
})
|
|
|
|
phase(t, "a built-in role cannot be edited or deleted", func(t *testing.T) {
|
|
type listing struct {
|
|
Roles []struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
System bool `json:"system"`
|
|
} `json:"roles"`
|
|
}
|
|
result := decode[listing](t, j.admin.get(host, "/api/v1/roles?tenant_id="+j.scopedTenantID, nil), http.StatusOK)
|
|
var ownerID string
|
|
for _, role := range result.Roles {
|
|
if role.Slug == "owner" {
|
|
ownerID = role.ID
|
|
}
|
|
}
|
|
if ownerID == "" {
|
|
t.Fatal("the owner role is missing")
|
|
}
|
|
response := j.admin.json(http.MethodPut, host, "/api/v1/roles/"+ownerID, map[string]any{
|
|
"name": "Hijacked", "permissions": []string{"console.read"},
|
|
})
|
|
if response.StatusCode == http.StatusOK {
|
|
t.Error("a built-in role was renamed")
|
|
response.Body.Close()
|
|
} else {
|
|
response.Body.Close()
|
|
}
|
|
response = j.admin.request(http.MethodDelete, host, "/api/v1/roles/"+ownerID, nil,
|
|
map[string]string{"Origin": "http://" + host})
|
|
if response.StatusCode == http.StatusNoContent {
|
|
t.Error("a built-in role was deleted")
|
|
response.Body.Close()
|
|
} else {
|
|
response.Body.Close()
|
|
}
|
|
})
|
|
}
|