280 lines
11 KiB
Go
280 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()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|