diff --git a/admin_permissions.go b/admin_permissions.go index 221084a..df896dd 100644 --- a/admin_permissions.go +++ b/admin_permissions.go @@ -1,16 +1,22 @@ package user -import "git.golem15.com/golem15/summercms/modules/pact" +import ( + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/summercms/modules/pact" +) const userPermissionTab = "golem15.user::lang.plugin.tab" -// Permissions is the plugin's Winter registerPermissions() catalog plus one -// code of its own. The four PHP codes default to the developer role; +// Permissions is the plugin's Winter registerPermissions() catalog plus two +// codes of its own. The four PHP codes default to the developer role; // impersonate_user is registered so imported backend roles that name it stay // valid, although no screen uses it. The fifth code // (classes.PermissionManagePrivilegedGroups) has no default role: only a // superuser, or a role that was granted it explicitly, may change the email -// or password of a privileged-group member or delete one. +// or password of a privileged-group member or delete one. The sixth +// (classes.PermissionManageBackendLinks) has no default role either: it +// links and unlinks another administrator's frontend account on the +// Administrators screen. func (*Plugin) Permissions() []pact.Permission { return []pact.Permission{ { @@ -42,5 +48,10 @@ func (*Plugin) Permissions() []pact.Permission { Tab: userPermissionTab, Label: "golem15.user::lang.plugin.manage_privileged_groups", }, + { + Code: classes.PermissionManageBackendLinks, + Tab: userPermissionTab, + Label: "golem15.user::lang.plugin.manage_backend_links", + }, } } diff --git a/admin_registration_test.go b/admin_registration_test.go index dc7b9ed..bcac331 100644 --- a/admin_registration_test.go +++ b/admin_registration_test.go @@ -29,7 +29,7 @@ var langKey = regexp.MustCompile(`golem15\.user::lang\.[a-z0-9_]+(\.[a-z0-9_]+)+ func TestAdminRegistration(t *testing.T) { plugin := &Plugin{} - // The five permission codes, each on the plugin's tab. + // The six permission codes, each on the plugin's tab. var codes []string for _, permission := range plugin.Permissions() { codes = append(codes, permission.Code) @@ -40,14 +40,15 @@ func TestAdminRegistration(t *testing.T) { wantCodes := []string{ "golem15.users.access_users", "golem15.users.access_groups", "golem15.users.access_settings", "golem15.users.impersonate_user", "golem15.users.manage_privileged_groups", + "golem15.users.manage_backend_links", } if !reflect.DeepEqual(codes, wantCodes) { t.Fatalf("permission codes = %v, want %v", codes, wantCodes) } - // The privileged-groups permission has no default role: nobody holds it - // until it is granted on purpose. + // The privileged-groups and backend-links permissions have no default + // role: nobody holds them until they are granted on purpose. for _, permission := range plugin.Permissions() { - if privileged := permission.Code == permManagePrivileged; privileged != (len(permission.Roles) == 0) { + if privileged := permission.Code == permManagePrivileged || permission.Code == permManageBackendLinks; privileged != (len(permission.Roles) == 0) { t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles) } } diff --git a/admin_users_test.go b/admin_users_test.go index 75ec897..087fce0 100644 --- a/admin_users_test.go +++ b/admin_users_test.go @@ -49,7 +49,7 @@ func TestAdminUsersTracer(t *testing.T) { if want := []string{ "golem15.users.access_users@developer", "golem15.users.access_groups@developer", "golem15.users.access_settings@developer", "golem15.users.impersonate_user@developer", - classes.PermissionManagePrivilegedGroups + "@-", + classes.PermissionManagePrivilegedGroups + "@-", classes.PermissionManageBackendLinks + "@-", }; !reflect.DeepEqual(codes, want) || classes.PermissionManagePrivilegedGroups != permManagePrivileged { t.Fatalf("permissions = %v", codes) } diff --git a/backend_link_admin_smoke_test.go b/backend_link_admin_smoke_test.go new file mode 100644 index 0000000..879867c --- /dev/null +++ b/backend_link_admin_smoke_test.go @@ -0,0 +1,151 @@ +package user + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "strconv" + "strings" + "sync" + "testing" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" +) + +const permManageBackendLinks = "golem15.users.manage_backend_links" + +// administratorPath is the admin API path of one administrator on the +// Administrators screen, with an optional suffix. +func administratorPath(id uint, suffix string) string { + return adminAPI("/summercms/cabana/users/" + strconv.FormatUint(uint64(id), 10) + suffix) +} + +// superAdmin is admin with the superuser flag set. +func (e *adminEnv) superAdmin(t *testing.T) string { + t.Helper() + token := e.admin(t) + if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = ?`, adminID(t, token)).Error; err != nil { + t.Fatal(err) + } + return token +} + +// administratorActions returns the record actions the Administrators screen +// offers on an administrator. +func (e *adminEnv) administratorActions(t *testing.T, token string, id uint) []string { + t.Helper() + rec := e.expect(t, http.StatusOK, http.MethodGet, administratorPath(id, ""), token, nil) + var meta struct { + Actions []struct { + Name string `json:"name"` + } `json:"actions"` + } + if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil { + t.Fatal(err) + } + names := []string{} + for _, action := range meta.Actions { + if strings.HasSuffix(action.Name, "frontend_account") { + names = append(names, action.Name) + } + } + return names +} + +// TestBackendLinkAdminSmoke: on the Administrators screen an administrator +// with golem15.users.manage_backend_links links a frontend account to +// another administrator by its exact email (any letter case) and unlinks +// it; linked_by records the caller, the event fires, a taken or unknown +// account is a 422 on the prompt, and an administrator without the +// permission is neither offered the actions nor allowed to run them. +func TestBackendLinkAdminSmoke(t *testing.T) { + env := newAdminEnv(t) + var mu sync.Mutex + var linked []models.BackendLink + env.app.Events.Listen("test.backend_linked", func(_ context.Context, e *classes.BackendLinkedEvent) error { + mu.Lock() + linked = append(linked, *e.Link) + mu.Unlock() + return nil + }) + stamp := adminStamp() + editor := env.seedUser(t, "editor", func(u *models.User) { u.Email = "editor-" + stamp + "@example.test" }) + second := env.seedUser(t, "second", nil) + targetToken := env.admin(t) + target := adminID(t, targetToken) + super := env.superAdmin(t) + superID := adminID(t, super) + resolver, _ := env.app.Lookup[pact.MCPLinkedBackendResolver]() + + if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) { + t.Fatalf("offered before the link = %v", got) + } + link := func(token, value string) *http.Response { + t.Helper() + rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), token, map[string]any{"payload": value}) + return rec.Result() + } + + // Unknown email: 422 on payload with the unknown_user text. + rec := env.call(t, http.MethodPost, administratorPath(target, "/actions/link_frontend_account"), super, map[string]any{"payload": "nobody-" + stamp + "@example.test"}) + if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "No active frontend user has this email.") { + t.Fatalf("unknown email = %d %s", rec.Code, rec.Body.String()) + } + + // The email matches whatever its letter case. + if res := link(super, " Editor-"+stamp+"@Example.TEST "); res.StatusCode != http.StatusOK { + t.Fatalf("set = %d", res.StatusCode) + } + var row models.BackendLink + if err := env.gdb.Where("backend_user_id = ?", target).Take(&row).Error; err != nil || row.UserID != editor.ID || row.LinkedBy == nil || *row.LinkedBy != superID { + t.Fatalf("link row = %+v %v", row, err) + } + if got, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || !found || got.ID != target { + t.Fatalf("LinkedBackendUser = %+v %v %v", got, found, err) + } + mu.Lock() + if len(linked) != 1 || linked[0].UserID != editor.ID || linked[0].BackendUserID != target { + t.Fatalf("BackendLinkedEvent saw %+v", linked) + } + mu.Unlock() + if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"unlink_frontend_account"}) { + t.Fatalf("offered after the link = %v", got) + } + + // The administrator is linked: a second set no longer applies (409), + // and the same frontend user cannot be linked to another administrator. + if res := link(super, second.Email); res.StatusCode != http.StatusConflict { + t.Fatalf("second set on a linked administrator = %d", res.StatusCode) + } + otherToken := env.admin(t) + other := adminID(t, otherToken) + rec = env.call(t, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), super, map[string]any{"payload": editor.Email}) + if rec.Code != http.StatusUnprocessableEntity || !slices.Contains(adminDecode(t, rec).Error.Details["payload"], "This administrator or this frontend user is already linked.") { + t.Fatalf("frontend user linked twice = %d %s", rec.Code, rec.Body.String()) + } + + // Without the permission: not offered, and refused. + limited := env.admin(t, cabana.PermissionManageUsers) + if got := env.administratorActions(t, limited, target); len(got) != 0 { + t.Fatalf("limited administrator is offered %v", got) + } + env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), limited, map[string]any{}) + env.expect(t, http.StatusForbidden, http.MethodPost, administratorPath(other, "/actions/link_frontend_account"), limited, map[string]any{"payload": second.Email}) + manager := env.admin(t, cabana.PermissionManageUsers, permManageBackendLinks) + if got := env.administratorActions(t, manager, target); !slices.Equal(got, []string{"unlink_frontend_account"}) { + t.Fatalf("manager is offered %v", got) + } + + // Unlink. + env.expect(t, http.StatusOK, http.MethodPost, administratorPath(target, "/actions/unlink_frontend_account"), manager, map[string]any{}) + if _, found, err := resolver.LinkedBackendUser(context.Background(), editor.ID); err != nil || found { + t.Fatalf("after unlink found=%v err=%v", found, err) + } + if got := env.administratorActions(t, super, target); !slices.Equal(got, []string{"link_frontend_account"}) { + t.Fatalf("offered after the unlink = %v", got) + } +} diff --git a/backend_links_admin.go b/backend_links_admin.go new file mode 100644 index 0000000..d7dd978 --- /dev/null +++ b/backend_links_admin.go @@ -0,0 +1,128 @@ +package user + +import ( + "context" + "errors" + "strings" + + "git.golem15.com/golem15/sm-user-plugin/classes" + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +var _ pact.HasAdminActionExtensions = (*Plugin)(nil) + +// AdminActionExtensions adds two record actions to the admin's +// Administrators screen (D-07, D-34), both behind +// golem15.users.manage_backend_links: link_frontend_account asks for the +// email of an active frontend user and links that user to the +// administrator shown (exact, case-insensitive match; no search and no +// list), recording who set it; unlink_frontend_account removes the +// administrator's link. cabana's outranking check runs before either. +func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension { + return []pact.AdminActionExtension{{ + Controller: cabana.AdministratorsControllerID, + RecordActions: []pact.AdminRecordAction{ + { + Name: "link_frontend_account", + Label: backendLinkLang + "set", + Confirm: backendLinkLang + "set_confirm", + Permissions: []string{classes.PermissionManageBackendLinks}, + Prompt: &pact.AdminActionPrompt{ + Label: backendLinkLang + "prompt_email", + Comment: backendLinkLang + "prompt_comment", + MaxLength: 254, + }, + Applies: func(ctx context.Context, record any) (bool, error) { + link, err := p.adminRecordLink(ctx, record) + return link == nil, err + }, + Run: p.setFrontendLink, + }, + { + Name: "unlink_frontend_account", + Label: backendLinkLang + "remove", + Confirm: backendLinkLang + "remove_confirm", + Danger: true, + Permissions: []string{classes.PermissionManageBackendLinks}, + Applies: func(ctx context.Context, record any) (bool, error) { + link, err := p.adminRecordLink(ctx, record) + return link != nil, err + }, + Run: p.removeFrontendLink, + }, + }, + }} +} + +// adminRecordLink returns the link of the administrator record, or nil. +func (p *Plugin) adminRecordLink(ctx context.Context, record any) (*models.BackendLink, error) { + admin, ok := record.(*cabana.BackendUser) + if !ok || admin == nil { + return nil, errors.New("golem15.user: the record is not an administrator") + } + return p.backendLinks().LinkFor(ctx, admin.ID) +} + +// payloadError is the 422 on the prompt's input. +func payloadError(key string) error { + return &cabana.ValidationError{Details: map[string]any{"payload": []string{backendLinkLang + key}}} +} + +// setFrontendLink links the active frontend user whose email is the prompt +// value to the administrator record. +func (p *Plugin) setFrontendLink(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + admin, ok := in.Record.(*cabana.BackendUser) + if !ok || admin == nil { + return pact.AdminRecordActionResult{}, errors.New("golem15.user: the record is not an administrator") + } + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AdminRecordActionResult{}, errors.New("golem15.user: linking needs the action's transaction") + } + email := strings.TrimSpace(in.Value) + var ids []uint + if err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Model(&models.User{}). + Where("lower(email) = lower(?) AND is_activated = TRUE", email).Limit(2).Pluck("id", &ids).Error; err != nil { + return pact.AdminRecordActionResult{}, err + } + if email == "" || len(ids) != 1 { + return pact.AdminRecordActionResult{}, payloadError("unknown_user") + } + var by uint + if caller, ok := bouncer.User(ctx); ok && caller != nil { + by = caller.ID + } + _, err := p.backendLinks().SetLink(ctx, tx, ids[0], admin.ID, by) + switch { + case err == nil: + case errors.Is(err, classes.ErrAlreadyLinked): + return pact.AdminRecordActionResult{}, payloadError("already_linked") + case errors.Is(err, classes.ErrFrontendUserUnavailable): + return pact.AdminRecordActionResult{}, payloadError("unknown_user") + case errors.Is(err, classes.ErrBackendUserUnavailable): + return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{} + default: + return pact.AdminRecordActionResult{}, err + } + return pact.AdminRecordActionResult{Message: backendLinkLang + "set_success"}, nil +} + +// removeFrontendLink removes the administrator record's link. +func (p *Plugin) removeFrontendLink(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return pact.AdminRecordActionResult{}, errors.New("golem15.user: unlinking needs the action's transaction") + } + link, err := p.adminRecordLink(ctx, in.Record) + if err != nil { + return pact.AdminRecordActionResult{}, err + } + if err := p.backendLinks().RemoveLink(ctx, tx, link); err != nil { + return pact.AdminRecordActionResult{}, err + } + return pact.AdminRecordActionResult{Message: backendLinkLang + "remove_success"}, nil +} diff --git a/classes/privileged.go b/classes/privileged.go index 0bcc80f..810672d 100644 --- a/classes/privileged.go +++ b/classes/privileged.go @@ -15,6 +15,11 @@ import ( // good. const PermissionManagePrivilegedGroups = "golem15.users.manage_privileged_groups" +// PermissionManageBackendLinks is the backend permission an administrator +// needs to link a frontend account to another administrator, or to unlink +// one, on the Administrators screen. No role has it by default. +const PermissionManageBackendLinks = "golem15.users.manage_backend_links" + // privilegedGroupsKey is the config list of privileged group codes. const privilegedGroupsKey = "golem15.user.privileged_groups" diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 863a2fb..0cae1ce 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -8,6 +8,7 @@ plugin: access_settings: Manage User Settings impersonate_user: Impersonate Users manage_privileged_groups: Manage privileged group membership + manage_backend_links: Link frontend accounts to administrators users: menu_label: Users new_user: New User diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 8445c67..06c1605 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -8,6 +8,7 @@ plugin: access_settings: Zarządzaj ustawieniami użytkownika impersonate_user: Wcielaj się w użytkowników manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych + manage_backend_links: Łącz konta na stronie z administratorami users: menu_label: Użytkownicy new_user: Nowy użytkownik