test(19.6.1-03): save-path privilege rule tests
- TestAdministratorsSuperuserHidden (D-03, scoped-token superusers) - TestAdministratorsSuperuserProjected (Pitfall 1) - TestAdministratorsPermissionCeiling and TestAdministratorsRoleCeiling (D-06, D-07) - TestAdministratorsSelfEdit (D-08), TestAdministratorsPasswordAndIdentity (D-11) - TestAdministratorsOutranked (D-19), TestAdministratorsOwnPassword (D-20) - TestRoleCeiling (-short) against grantsCovered
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
@@ -90,3 +94,580 @@ func TestAdministratorsSuperuserFlag(t *testing.T) {
|
||||
t.Fatal("the superuser flag was not granted on update")
|
||||
}
|
||||
}
|
||||
|
||||
// listSchemaKeys returns a list schema response's column keys and filter
|
||||
// names.
|
||||
func listSchemaKeys(body map[string]any) (map[string]bool, map[string]bool) {
|
||||
columns, filters := map[string]bool{}, map[string]bool{}
|
||||
data := dataOf(body)
|
||||
items, _ := data["columns"].([]any)
|
||||
for _, item := range items {
|
||||
column, _ := item.(map[string]any)
|
||||
key, _ := column["key"].(string)
|
||||
columns[key] = true
|
||||
}
|
||||
items, _ = data["filters"].([]any)
|
||||
for _, item := range items {
|
||||
filter, _ := item.(map[string]any)
|
||||
name, _ := filter["name"].(string)
|
||||
filters[name] = true
|
||||
}
|
||||
return columns, filters
|
||||
}
|
||||
|
||||
// TestAdministratorsSuperuserHidden (D-03): an administrator who does not act
|
||||
// as a superuser never sees a superuser, in the list, any lookup or the
|
||||
// schemas; a superuser's scoped API token does not act as a superuser.
|
||||
func TestAdministratorsSuperuserHidden(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
super := env.seedAdmin(t, adminSeed{Name: "hidden-super", Superuser: true})
|
||||
superToken := env.login(t, super.Login)
|
||||
delegate := env.seedAdmin(t, adminSeed{Name: "hidden-delegate", RolePermissions: manageUsers})
|
||||
delegateToken := env.login(t, delegate.Login)
|
||||
plain := env.seedAdmin(t, adminSeed{Name: "hidden-plain"})
|
||||
|
||||
for _, query := range []string{
|
||||
"?search=" + env.stamp,
|
||||
"?search=" + super.Login,
|
||||
"?search=" + env.stamp + "&filter[is_superuser]=1",
|
||||
"?search=" + env.stamp + "&filter[activated]=1",
|
||||
"?search=" + env.stamp + "&filter[trashed]=hide",
|
||||
} {
|
||||
rec := env.call(t, http.MethodGet, usersPath+query, delegateToken, nil)
|
||||
if rec.Code == http.StatusOK {
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if listIDs(t, body)[super.ID] {
|
||||
t.Fatalf("list %s shows the superuser to a delegate", query)
|
||||
}
|
||||
} else if rec.Code >= 500 {
|
||||
t.Fatalf("list %s status=%d", query, rec.Code)
|
||||
}
|
||||
}
|
||||
if ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, delegateToken, nil)); !ids[plain.ID] || !ids[delegate.ID] {
|
||||
t.Fatalf("delegate list = %v, want %d and %d", ids, plain.ID, delegate.ID)
|
||||
}
|
||||
|
||||
before := env.userRow(t, super.ID)
|
||||
env.expectRefusal(t, http.StatusNotFound, http.MethodGet, userURL(super.ID), delegateToken, nil, "")
|
||||
env.expectRefusal(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/partials/hint?id=%d", usersPath, super.ID), delegateToken, nil, "")
|
||||
env.expectRefusal(t, http.StatusNotFound, http.MethodPost, userURL(super.ID, "actions", "deactivate"), delegateToken, map[string]any{}, "")
|
||||
env.expectRefusal(t, http.StatusNotFound, http.MethodPut, userURL(super.ID), delegateToken,
|
||||
map[string]any{"login": super.Login, "email": super.Email, "first_name": "Hijacked"}, "")
|
||||
// The framework's delete answers a missing id with deleted 0; a hidden
|
||||
// superuser gets exactly that answer, so it reveals nothing.
|
||||
hiddenDelete := env.call(t, http.MethodDelete, userURL(super.ID), delegateToken, nil)
|
||||
missingDelete := env.call(t, http.MethodDelete, userURL(2000000000), delegateToken, nil)
|
||||
if hiddenDelete.Code != missingDelete.Code || hiddenDelete.Body.String() != missingDelete.Body.String() {
|
||||
t.Fatalf("delete of a hidden superuser %d %s, of a missing id %d %s", hiddenDelete.Code, hiddenDelete.Body.String(), missingDelete.Code, missingDelete.Body.String())
|
||||
}
|
||||
// A bulk selection never resolves to the superuser.
|
||||
if rec := env.call(t, http.MethodPost, usersPath+"/bulk/deactivate", delegateToken, map[string]any{"ids": []uint{super.ID}}); rec.Code >= 500 || rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), `"affected":1`) {
|
||||
t.Fatalf("bulk deactivate of a hidden superuser status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
env.unchanged(t, super.ID, before, "lookups of a hidden superuser")
|
||||
|
||||
form := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", delegateToken, nil))
|
||||
if _, ok := form["is_superuser"]; ok {
|
||||
t.Fatal("the delegate's form schema has is_superuser")
|
||||
}
|
||||
columns, filters := listSchemaKeys(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/list", delegateToken, nil))
|
||||
if columns["is_superuser"] || filters["is_superuser"] || !columns["login"] || !filters["role"] {
|
||||
t.Fatalf("delegate list schema columns=%v filters=%v", columns, filters)
|
||||
}
|
||||
|
||||
// A superuser sees all three, and the other superuser.
|
||||
form = schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", superToken, nil))
|
||||
if _, ok := form["is_superuser"]; !ok {
|
||||
t.Fatal("the superuser's form schema lacks is_superuser")
|
||||
}
|
||||
columns, filters = listSchemaKeys(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/list", superToken, nil))
|
||||
if !columns["is_superuser"] || !filters["is_superuser"] {
|
||||
t.Fatalf("superuser list schema columns=%v filters=%v", columns, filters)
|
||||
}
|
||||
other := env.seedAdmin(t, adminSeed{Name: "hidden-other", Superuser: true})
|
||||
if ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, superToken, nil)); !ids[other.ID] || !ids[super.ID] {
|
||||
t.Fatalf("superuser list = %v, want both superusers", ids)
|
||||
}
|
||||
|
||||
// A superuser's scoped token does not act as a superuser; a full one does.
|
||||
scoped, err := cabana.IssueTestAPIToken(env.gdb, super.ID, []string{pingScope}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full, err := cabana.IssueTestAPIToken(env.gdb, super.ID, []string{"*"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, scoped, nil))
|
||||
if ids[other.ID] || !ids[plain.ID] {
|
||||
t.Fatalf("scoped token list = %v, want the plain admin and no superuser", ids)
|
||||
}
|
||||
if _, ok := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", scoped, nil))["is_superuser"]; ok {
|
||||
t.Fatal("the scoped token's form schema has is_superuser")
|
||||
}
|
||||
before = env.userRow(t, plain.ID)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(plain.ID), scoped,
|
||||
map[string]any{"login": plain.Login, "email": plain.Email, "is_superuser": true}, "is_superuser", enSuperuserForbidden, true)
|
||||
env.unchanged(t, plain.ID, before, "a scoped token's is_superuser update")
|
||||
if ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, full, nil)); !ids[other.ID] {
|
||||
t.Fatalf("full token list = %v, want the other superuser", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// editablePayload is the save body the admin builds from a record GET: every
|
||||
// editable field of the form with its loaded value, the passwords blank.
|
||||
func editablePayload(record map[string]any, form map[string]map[string]any) map[string]any {
|
||||
body := map[string]any{"password": "", "password_confirmation": ""}
|
||||
for name := range form {
|
||||
if name == "password" || name == "password_confirmation" || name == "last_login" {
|
||||
continue
|
||||
}
|
||||
if value, ok := record[name]; ok {
|
||||
body[name] = value
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// TestAdministratorsSuperuserProjected (Pitfall 1): a superuser reads another
|
||||
// superuser's stored flag, and saving the untouched form keeps it.
|
||||
func TestAdministratorsSuperuserProjected(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
super := env.seedAdmin(t, adminSeed{Name: "proj-super", Superuser: true})
|
||||
superToken := env.login(t, super.Login)
|
||||
other := env.seedAdmin(t, adminSeed{Name: "proj-other", Superuser: true, Permissions: map[string]int{pingPermission: 1}})
|
||||
|
||||
record := dataOf(env.expect(t, http.StatusOK, http.MethodGet, userURL(other.ID), superToken, nil))
|
||||
if record["is_superuser"] != true {
|
||||
t.Fatalf("record data is_superuser = %v", record["is_superuser"])
|
||||
}
|
||||
form := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", superToken, nil))
|
||||
body := editablePayload(record, form)
|
||||
if _, ok := body["is_superuser"]; !ok {
|
||||
t.Fatalf("payload lacks is_superuser: %v", body)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(other.ID), superToken, body)
|
||||
got := env.storedUser(t, other.ID)
|
||||
if !got.IsSuperuser || got.Permissions != `{"`+pingPermission+`":1}` {
|
||||
t.Fatalf("after an untouched save = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdministratorsPermissionCeiling (D-06, D-07): a delegate may set only
|
||||
// the overrides of codes it holds itself; stored values of other codes are
|
||||
// kept, and an override is merged last over the role.
|
||||
func TestAdministratorsPermissionCeiling(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
delegate := env.seedAdmin(t, adminSeed{Name: "ceil-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}})
|
||||
delegateToken := env.login(t, delegate.Login)
|
||||
target := env.seedAdmin(t, adminSeed{Name: "ceil-target", RolePermissions: manageUsers})
|
||||
targetToken := env.login(t, target.Login)
|
||||
|
||||
locked := permissionLocked(schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", delegateToken, nil))["permissions"])
|
||||
if len(locked) < 4 {
|
||||
t.Fatalf("permission options = %v", locked)
|
||||
}
|
||||
for code, isLocked := range locked {
|
||||
held := code == cabana.PermissionManageUsers || code == pingPermission
|
||||
if isLocked == held {
|
||||
t.Fatalf("option %s locked=%v, want %v", code, isLocked, !held)
|
||||
}
|
||||
}
|
||||
|
||||
for _, value := range []int{1, -1} {
|
||||
for _, code := range []string{"acme.demo.access", cabana.PermissionManageRoles} {
|
||||
before := env.userRow(t, target.ID)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(target.ID), delegateToken,
|
||||
map[string]any{"login": target.Login, "email": target.Email, "permissions": map[string]any{code: value}},
|
||||
"permissions", enPermissionLocked, false)
|
||||
env.unchanged(t, target.ID, before, "a locked override")
|
||||
}
|
||||
}
|
||||
|
||||
// A stored value of a locked code is kept when the delegate saves.
|
||||
keeper := env.seedAdmin(t, adminSeed{Name: "ceil-keeper", Permissions: map[string]int{"acme.demo.access": -1}})
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(keeper.ID), delegateToken,
|
||||
map[string]any{"login": keeper.Login, "email": keeper.Email, "first_name": "Kept"})
|
||||
if got := env.storedUser(t, keeper.ID); got.FirstName != "Kept" || got.Permissions != `{"acme.demo.access":-1}` {
|
||||
t.Fatalf("keeper after a save without permissions = %+v", got)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(keeper.ID), delegateToken,
|
||||
map[string]any{"login": keeper.Login, "email": keeper.Email, "permissions": map[string]any{"acme.demo.access": -1, pingPermission: 1}})
|
||||
if got := decodeJSON(t, env.storedUser(t, keeper.ID).Permissions); got["acme.demo.access"] != -1 || got[pingPermission] != 1 || len(got) != 2 {
|
||||
t.Fatalf("keeper overrides = %v", got)
|
||||
}
|
||||
before := env.userRow(t, keeper.ID)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(keeper.ID), delegateToken,
|
||||
map[string]any{"login": keeper.Login, "email": keeper.Email, "permissions": map[string]any{pingPermission: 1}},
|
||||
"permissions", enPermissionLocked, false)
|
||||
env.unchanged(t, keeper.ID, before, "dropping a locked stored override")
|
||||
|
||||
// Overrides of held codes save and are merged last over the role.
|
||||
if rec := env.call(t, http.MethodGet, usersPath, targetToken, nil); rec.Code != http.StatusOK {
|
||||
t.Fatalf("target list before status=%d", rec.Code)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), delegateToken,
|
||||
map[string]any{"login": target.Login, "email": target.Email, "permissions": map[string]any{cabana.PermissionManageUsers: -1}})
|
||||
if rec := env.call(t, http.MethodGet, usersPath, targetToken, nil); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("target list after a deny override status=%d", rec.Code)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), delegateToken,
|
||||
map[string]any{"login": target.Login, "email": target.Email, "permissions": map[string]any{pingPermission: 1}})
|
||||
if got := decodeJSON(t, env.storedUser(t, target.ID).Permissions); got[pingPermission] != 1 || len(got) != 1 {
|
||||
t.Fatalf("target overrides = %v", got)
|
||||
}
|
||||
show := env.expect(t, http.StatusOK, http.MethodGet, userURL(keeper.ID), targetToken, nil)
|
||||
if names := recordActionNames(show); !containsName(names, "acme_ping") {
|
||||
t.Fatalf("an allowed override did not reach the target: actions %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, raw string) map[string]int {
|
||||
t.Helper()
|
||||
out := map[string]int{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
t.Fatalf("permissions %q: %v", raw, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsName(names []string, want string) bool {
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// roleOptions returns the role field's options as id to locked.
|
||||
func (e *screensEnv) roleOptions(t *testing.T, token, search string) map[uint]bool {
|
||||
t.Helper()
|
||||
body := e.expect(t, http.StatusOK, http.MethodGet, usersPath+"/fields/role/options?per_page=100&search="+search, token, nil)
|
||||
rows, _ := body["data"].([]any)
|
||||
out := map[uint]bool{}
|
||||
for _, row := range rows {
|
||||
option, _ := row.(map[string]any)
|
||||
id, _ := option["value"].(float64)
|
||||
locked, _ := option["locked"].(bool)
|
||||
out[uint(id)] = locked
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestAdministratorsRoleCeiling (D-07): a delegate may assign only roles
|
||||
// whose grants it holds; a held role above the ceiling stays unchanged.
|
||||
func TestAdministratorsRoleCeiling(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
delegate := env.seedAdmin(t, adminSeed{Name: "rc-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}})
|
||||
delegateToken := env.login(t, delegate.Login)
|
||||
above := env.seedRole(t, roleSeed{Name: "rc-above", Permissions: map[string]int{"acme.demo.access": 1}})
|
||||
covered := env.seedRole(t, roleSeed{Name: "rc-covered", Permissions: map[string]int{pingPermission: 1}})
|
||||
target := env.seedAdmin(t, adminSeed{Name: "rc-target"})
|
||||
// Holds the above-ceiling role, with its extra grant denied: it does not
|
||||
// outrank the delegate, but its role stays locked.
|
||||
holder := env.seedAdmin(t, adminSeed{Name: "rc-holder", Role: above.ID, Permissions: map[string]int{"acme.demo.access": -1}})
|
||||
|
||||
options := env.roleOptions(t, delegateToken, env.stamp)
|
||||
if locked, ok := options[above.ID]; !ok || !locked {
|
||||
t.Fatalf("above-ceiling role option = %v (present %v)", locked, ok)
|
||||
}
|
||||
if locked, ok := options[covered.ID]; !ok || locked {
|
||||
t.Fatalf("covered role option = %v (present %v)", locked, ok)
|
||||
}
|
||||
var developer uint
|
||||
if err := env.gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&developer).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if locked := env.roleOptions(t, delegateToken, "Developer")[developer]; !locked {
|
||||
t.Fatal("the developer role (code-declared acme.demo.access) is not locked for the delegate")
|
||||
}
|
||||
|
||||
before := env.userRow(t, target.ID)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(target.ID), delegateToken,
|
||||
map[string]any{"login": target.Login, "email": target.Email, "role": above.ID}, "role", enRoleCeiling, true)
|
||||
env.unchanged(t, target.ID, before, "assigning an above-ceiling role")
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(holder.ID), delegateToken,
|
||||
map[string]any{"login": holder.Login, "email": holder.Email, "first_name": "Renamed", "role": above.ID})
|
||||
if got := env.storedUser(t, holder.ID); got.FirstName != "Renamed" || got.RoleID == nil || *got.RoleID != above.ID {
|
||||
t.Fatalf("holder after a rename = %+v", got)
|
||||
}
|
||||
before = env.userRow(t, holder.ID)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(holder.ID), delegateToken,
|
||||
map[string]any{"login": holder.Login, "email": holder.Email, "role": covered.ID}, "role", enRoleCeiling, true)
|
||||
env.unchanged(t, holder.ID, before, "moving off an above-ceiling role")
|
||||
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), delegateToken,
|
||||
map[string]any{"login": target.Login, "email": target.Email, "role": covered.ID})
|
||||
if got := env.storedUser(t, target.ID); got.RoleID == nil || *got.RoleID != covered.ID {
|
||||
t.Fatalf("target role = %v, want %d", got.RoleID, covered.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdministratorsSelfEdit (D-08): an administrator edits their own name,
|
||||
// login and email, never their own role, superuser flag, overrides,
|
||||
// activation or existence.
|
||||
func TestAdministratorsSelfEdit(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
other := env.seedRole(t, roleSeed{Name: "self-other", Permissions: manageUsers})
|
||||
delegate := env.seedAdmin(t, adminSeed{Name: "self-delegate", RolePermissions: manageUsers})
|
||||
delegateToken := env.login(t, delegate.Login)
|
||||
super := env.seedAdmin(t, adminSeed{Name: "self-super", Superuser: true})
|
||||
superToken := env.login(t, super.Login)
|
||||
peer := env.seedAdmin(t, adminSeed{Name: "self-peer"})
|
||||
|
||||
login := env.unique("self-renamed")
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(delegate.ID), delegateToken,
|
||||
map[string]any{"login": login, "email": login + "@example.test", "first_name": "Own"})
|
||||
self := env.storedUser(t, delegate.ID)
|
||||
if self.Login != login || self.Email != login+"@example.test" || self.FirstName != "Own" {
|
||||
t.Fatalf("own edit = %+v", self)
|
||||
}
|
||||
identity := map[string]any{"login": self.Login, "email": self.Email}
|
||||
with := func(key string, value any) map[string]any {
|
||||
body := map[string]any{key: value}
|
||||
for k, v := range identity {
|
||||
body[k] = v
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// Own role: refused after assignment and rolled back.
|
||||
before := env.userRow(t, delegate.ID)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(delegate.ID), delegateToken,
|
||||
with("role", other.ID), "role", enSelfPrivileged, true)
|
||||
env.unchanged(t, delegate.ID, before, "an own role change")
|
||||
|
||||
// Own superuser flag, for a superuser.
|
||||
superBefore := env.userRow(t, super.ID)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(super.ID), superToken,
|
||||
map[string]any{"login": super.Login, "email": super.Email, "is_superuser": false}, "is_superuser", enSelfPrivileged, true)
|
||||
env.unchanged(t, super.ID, superBefore, "an own superuser removal")
|
||||
|
||||
// Own overrides: every code is record-locked on the own record.
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(delegate.ID), delegateToken,
|
||||
with("permissions", map[string]any{cabana.PermissionManageUsers: -1}), "permissions", enPermissionLocked, false)
|
||||
env.unchanged(t, delegate.ID, before, "an own override change")
|
||||
// An untouched own save (the record-locked overrides left out) passes.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(delegate.ID), delegateToken, with("first_name", "Own"))
|
||||
|
||||
// Own delete and own bulk deactivate.
|
||||
before = env.userRow(t, delegate.ID)
|
||||
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, userURL(delegate.ID), delegateToken, nil, enSelfDelete)
|
||||
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, usersPath+"/bulk-delete", delegateToken,
|
||||
map[string]any{"ids": []uint{peer.ID, delegate.ID}}, enSelfDelete)
|
||||
peerBefore := env.userRow(t, peer.ID)
|
||||
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, usersPath+"/bulk/deactivate", delegateToken,
|
||||
map[string]any{"ids": []uint{peer.ID, delegate.ID}}, enSelfDeactivate)
|
||||
env.unchanged(t, delegate.ID, before, "an own delete or deactivate")
|
||||
env.unchanged(t, peer.ID, peerBefore, "a bulk selection refused for the own row")
|
||||
|
||||
// Deactivate is not offered on the own preview, and does not run there.
|
||||
own := env.expect(t, http.StatusOK, http.MethodGet, userURL(delegate.ID), delegateToken, nil)
|
||||
if names := recordActionNames(own); containsName(names, "deactivate") {
|
||||
t.Fatalf("own preview offers %v", names)
|
||||
}
|
||||
if rec := env.call(t, http.MethodPost, userURL(delegate.ID, "actions", "deactivate"), delegateToken, map[string]any{}); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("own deactivate action status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
env.unchanged(t, delegate.ID, before, "an own deactivate action")
|
||||
}
|
||||
|
||||
// TestAdministratorsPasswordAndIdentity (D-11): the password rules on create
|
||||
// and update, and login and email unique ignoring case, deleted
|
||||
// administrators included.
|
||||
func TestAdministratorsPasswordAndIdentity(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
super := env.seedAdmin(t, adminSeed{Name: "pw-super", Superuser: true})
|
||||
superToken := env.login(t, super.Login)
|
||||
|
||||
long := strings.Repeat("p", 73)
|
||||
for _, tc := range []struct {
|
||||
name, password, confirmation, want string
|
||||
omit bool
|
||||
}{
|
||||
{name: "no password", omit: true, want: "required"},
|
||||
{name: "no confirmation", password: "pass-1234", confirmation: "", want: "confirm"},
|
||||
{name: "mismatch", password: "pass-1234", confirmation: "pass-5678", want: "confirm"},
|
||||
{name: "too short", password: "abc", confirmation: "abc", want: "at least 4 characters"},
|
||||
{name: "73 bytes", password: long, confirmation: long, want: "greater than 72"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
login := env.unique("pw-new")
|
||||
env.cleanupLogin(t, login)
|
||||
body := newAdminBody(login, nil)
|
||||
if tc.omit {
|
||||
delete(body, "password")
|
||||
delete(body, "password_confirmation")
|
||||
} else {
|
||||
body["password"], body["password_confirmation"] = tc.password, tc.confirmation
|
||||
}
|
||||
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, usersPath, superToken, body, "password", tc.want, false)
|
||||
if n := env.countLogin(t, login); n != 0 {
|
||||
t.Fatalf("a refused create left %d rows", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
login := env.unique("pw-valid")
|
||||
env.cleanupLogin(t, login)
|
||||
created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, superToken, newAdminBody(login, nil))
|
||||
id := uint(dataOf(created)["id"].(float64))
|
||||
stored := env.storedUser(t, id)
|
||||
if !strings.HasPrefix(stored.Password, "$2") || !bouncer.CheckPassword(stored.Password, "pass-1234") {
|
||||
t.Fatalf("stored hash %q does not verify", stored.Password)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(id), superToken,
|
||||
map[string]any{"login": login, "email": login + "@example.test", "first_name": "Kept", "password": "", "password_confirmation": ""})
|
||||
if got := env.storedUser(t, id); got.Password != stored.Password || got.FirstName != "Kept" {
|
||||
t.Fatalf("a blank password changed the hash: %+v", got)
|
||||
}
|
||||
|
||||
taken := env.seedAdmin(t, adminSeed{Name: "pw-taken"})
|
||||
gone := env.seedAdmin(t, adminSeed{Name: "pw-gone", Deleted: true})
|
||||
for _, tc := range []struct {
|
||||
name, field, login, email, want string
|
||||
}{
|
||||
{"login taken", "login", taken.Login, "", enLoginTaken},
|
||||
{"login taken in another case", "login", strings.ToUpper(taken.Login), "", enLoginTaken},
|
||||
{"login equal to another email", "login", taken.Email, "", enLoginTaken},
|
||||
{"email taken", "email", "", taken.Email, enEmailTaken},
|
||||
{"email taken in another case", "email", "", strings.ToUpper(taken.Email), enEmailTaken},
|
||||
{"login of a deleted admin", "login", gone.Login, "", enLoginTakenDeleted},
|
||||
{"email of a deleted admin", "email", "", gone.Email, enEmailTakenDeleted},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fresh := env.unique("pw-fresh")
|
||||
env.cleanupLogin(t, fresh)
|
||||
body := newAdminBody(fresh, nil)
|
||||
if tc.login != "" {
|
||||
body["login"] = tc.login
|
||||
}
|
||||
if tc.email != "" {
|
||||
body["email"] = tc.email
|
||||
}
|
||||
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, usersPath, superToken, body, tc.field, tc.want, false)
|
||||
if n := env.countLogin(t, fresh); n != 0 {
|
||||
t.Fatalf("a refused create left %d rows", n)
|
||||
}
|
||||
// The same value on update of another administrator.
|
||||
before := env.userRow(t, id)
|
||||
update := map[string]any{"login": login, "email": login + "@example.test"}
|
||||
if tc.login != "" {
|
||||
update["login"] = tc.login
|
||||
}
|
||||
if tc.email != "" {
|
||||
update["email"] = tc.email
|
||||
}
|
||||
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, userURL(id), superToken, update, tc.field, tc.want, false)
|
||||
env.unchanged(t, id, before, "a refused identity update")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdministratorsOutranked (D-19): a delegate cannot change, deactivate,
|
||||
// activate, restore or delete an administrator holding a grant the delegate
|
||||
// lacks; covered administrators pass.
|
||||
func TestAdministratorsOutranked(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
delegate := env.seedAdmin(t, adminSeed{Name: "out-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}})
|
||||
delegateToken := env.login(t, delegate.Login)
|
||||
strong := map[string]int{cabana.PermissionManageUsers: 1, "acme.demo.access": 1}
|
||||
active := env.seedAdmin(t, adminSeed{Name: "out-active", RolePermissions: strong})
|
||||
inactive := env.seedAdmin(t, adminSeed{Name: "out-inactive", RolePermissions: strong, Inactive: true})
|
||||
deleted := env.seedAdmin(t, adminSeed{Name: "out-deleted", RolePermissions: strong, Deleted: true})
|
||||
// A user-level allow outranks too (the override is merged last).
|
||||
override := env.seedAdmin(t, adminSeed{Name: "out-override", Permissions: map[string]int{"acme.demo.access": 1}})
|
||||
|
||||
type attempt struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body func(cabana.BackendUser) any
|
||||
target cabana.BackendUser
|
||||
}
|
||||
identity := func(u cabana.BackendUser) map[string]any { return map[string]any{"login": u.Login, "email": u.Email} }
|
||||
attempts := []attempt{
|
||||
{"update", http.MethodPut, userURL(active.ID), func(u cabana.BackendUser) any {
|
||||
b := identity(u)
|
||||
b["first_name"] = "Taken"
|
||||
return b
|
||||
}, active},
|
||||
{"password", http.MethodPut, userURL(active.ID), func(u cabana.BackendUser) any {
|
||||
b := identity(u)
|
||||
b["password"], b["password_confirmation"] = "pass-9999", "pass-9999"
|
||||
return b
|
||||
}, active},
|
||||
{"override update", http.MethodPut, userURL(override.ID), func(u cabana.BackendUser) any {
|
||||
b := identity(u)
|
||||
b["first_name"] = "Taken"
|
||||
return b
|
||||
}, override},
|
||||
{"delete", http.MethodDelete, userURL(active.ID), nil, active},
|
||||
{"bulk delete", http.MethodPost, usersPath + "/bulk-delete", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, active},
|
||||
{"record deactivate", http.MethodPost, userURL(active.ID, "actions", "deactivate"), func(cabana.BackendUser) any { return map[string]any{} }, active},
|
||||
{"record activate", http.MethodPost, userURL(inactive.ID, "actions", "activate"), func(cabana.BackendUser) any { return map[string]any{} }, inactive},
|
||||
{"record restore", http.MethodPost, userURL(deleted.ID, "actions", "restore"), func(cabana.BackendUser) any { return map[string]any{} }, deleted},
|
||||
{"bulk deactivate", http.MethodPost, usersPath + "/bulk/deactivate", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, active},
|
||||
{"bulk activate", http.MethodPost, usersPath + "/bulk/activate", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, inactive},
|
||||
{"bulk restore", http.MethodPost, usersPath + "/bulk/restore", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, deleted},
|
||||
}
|
||||
for _, a := range attempts {
|
||||
t.Run(a.name, func(t *testing.T) {
|
||||
var body any
|
||||
if a.body != nil {
|
||||
body = a.body(a.target)
|
||||
}
|
||||
before := env.userRow(t, a.target.ID)
|
||||
env.expectRefusal(t, http.StatusForbidden, a.method, a.path, delegateToken, body, enOutranked)
|
||||
env.unchanged(t, a.target.ID, before, a.name)
|
||||
})
|
||||
}
|
||||
|
||||
// Covered administrators pass.
|
||||
covered := env.seedAdmin(t, adminSeed{Name: "out-covered", RolePermissions: map[string]int{pingPermission: 1}})
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(covered.ID), delegateToken,
|
||||
map[string]any{"login": covered.Login, "email": covered.Email, "first_name": "Covered", "password": "pass-9999", "password_confirmation": "pass-9999"})
|
||||
env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "deactivate"), delegateToken, map[string]any{})
|
||||
env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "activate"), delegateToken, map[string]any{})
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, userURL(covered.ID), delegateToken, nil)
|
||||
env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "restore"), delegateToken, map[string]any{})
|
||||
if got := env.storedUser(t, covered.ID); got.FirstName != "Covered" || !got.IsActivated || got.DeletedAt.Valid || !bouncer.CheckPassword(got.Password, "pass-9999") {
|
||||
t.Fatalf("covered admin = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdministratorsOwnPassword (D-20, backend::lang.user.own_password): an
|
||||
// own password is changed in My account only; another administrator's
|
||||
// password is set here.
|
||||
func TestAdministratorsOwnPassword(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
delegate := env.seedAdmin(t, adminSeed{Name: "own-delegate", RolePermissions: manageUsers})
|
||||
delegateToken := env.login(t, delegate.Login)
|
||||
super := env.seedAdmin(t, adminSeed{Name: "own-super", Superuser: true})
|
||||
superToken := env.login(t, super.Login)
|
||||
other := env.seedAdmin(t, adminSeed{Name: "own-other"})
|
||||
|
||||
for _, who := range []struct {
|
||||
user cabana.BackendUser
|
||||
token string
|
||||
}{{delegate, delegateToken}, {super, superToken}} {
|
||||
before := env.userRow(t, who.user.ID)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(who.user.ID), who.token,
|
||||
map[string]any{"login": who.user.Login, "email": who.user.Email, "password": "pass-5678", "password_confirmation": "pass-5678"},
|
||||
"password", enOwnPassword, true)
|
||||
env.unchanged(t, who.user.ID, before, "an own password")
|
||||
if got := env.storedUser(t, who.user.ID); !bouncer.CheckPassword(got.Password, adminTestPassword) {
|
||||
t.Fatal("the own hash changed")
|
||||
}
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(other.ID), superToken,
|
||||
map[string]any{"login": other.Login, "email": other.Email, "password": "pass-5678", "password_confirmation": "pass-5678"})
|
||||
if got := env.storedUser(t, other.ID); !bouncer.CheckPassword(got.Password, "pass-5678") {
|
||||
t.Fatal("another administrator's password was not set")
|
||||
}
|
||||
}
|
||||
|
||||
47
modules/cabana/backend_privileges_internal_test.go
Normal file
47
modules/cabana/backend_privileges_internal_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package cabana
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRoleCeiling (D-07) pins grantsCovered: plain codes go through granted,
|
||||
// a wildcard a role grants is covered only by the same or a broader holder
|
||||
// wildcard.
|
||||
func TestRoleCeiling(t *testing.T) {
|
||||
set := func(codes ...string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, code := range codes {
|
||||
out[code] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
holder map[string]bool
|
||||
wanted map[string]bool
|
||||
want bool
|
||||
}{
|
||||
{"exact codes held", set("acme.blog.posts", "acme.blog.tags"), set("acme.blog.posts"), true},
|
||||
{"exact code missing", set("acme.blog.posts"), set("acme.blog.posts", "acme.blog.tags"), false},
|
||||
{"holder wildcard covers exact codes", set("acme.blog.*"), set("acme.blog.posts", "acme.blog.tags"), true},
|
||||
{"broader holder wildcard covers exact codes", set("acme.*"), set("acme.blog.posts"), true},
|
||||
{"holder wildcard of another prefix", set("acme.shop.*"), set("acme.blog.posts"), false},
|
||||
{"role wildcard covered by the same wildcard", set("acme.blog.*"), set("acme.blog.*"), true},
|
||||
{"role wildcard covered by a broader wildcard", set("acme.*"), set("acme.blog.*"), true},
|
||||
{"role wildcard not covered by a narrower wildcard", set("acme.blog.posts.*"), set("acme.blog.*"), false},
|
||||
{"role wildcard not covered by exact codes", set("acme.blog.posts", "acme.blog.tags"), set("acme.blog.*"), false},
|
||||
{"suffix wildcard covered only by itself", set("*.manage"), set("*.manage"), true},
|
||||
{"suffix wildcard not covered by exact codes", set("acme.manage", "shop.manage"), set("*.manage"), false},
|
||||
{"suffix wildcard not covered by a prefix wildcard", set("acme.*"), set("*.manage"), false},
|
||||
{"false role entries are ignored", set(), map[string]bool{"acme.blog.posts": false}, true},
|
||||
{"empty role", set("acme.blog.posts"), set(), true},
|
||||
{"nil role", nil, nil, true},
|
||||
{"nil holder and an empty role", nil, set(), true},
|
||||
{"nil holder and a granting role", nil, set("acme.blog.posts"), false},
|
||||
{"nil holder and a wildcard role", nil, set("acme.*"), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := grantsCovered(tc.holder, tc.wanted); got != tc.want {
|
||||
t.Fatalf("grantsCovered(%v, %v) = %v, want %v", tc.holder, tc.wanted, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user