feat(19-04): user authorizer, user.Deleting and handle backfill
Register lighthouse user:{id} if the namespace is free, fire user.Deleting before unscoped delete, and backfill missing handles.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
16
README.md
16
README.md
@@ -31,9 +31,11 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
||||
- **Privileged groups.** The groups whose code is listed in `golem15.user.privileged_groups` (default `admin`) are privileged: an application may treat their members as site administrators. Three things therefore need the backend permission `golem15.users.manage_privileged_groups` on top of the screen's own permission, and answer 403 without it, saving nothing. First, membership: adding a privileged group to a user or removing it, on create and on update of the user form. An administrator without the permission sees those groups locked in the field and can still change the user's other groups. Second, the code: creating a group with a privileged code, changing a group's code to or from a privileged one, and deleting a privileged group. Its name, description and frontend permissions need only `golem15.users.access_groups`. Codes compare case-sensitively, and a group without a code is never privileged. Third, the member's account, which is protected against takeover: changing that user's email or password, and deleting them for good, needs the backend permission `golem15.users.manage_privileged_groups` on top of `golem15.users.access_users`. Without it the request answers 403 and nothing is saved; a bulk delete that contains such a user deletes none of the selected users. Everything else on such a user (name, organisation, avatar, frontend permissions, activate, deactivate, restore, ban, unban, unsuspend) needs only `golem15.users.access_users`. `PrivilegedGroupCodes`, `IsPrivilegedCode`, `PrivilegedGroupIDs` and `IsPrivilegedMember` expose the rule to other code.
|
||||
- **Frontend permissions.** `golem15_user_frontend_permissions` is the catalogue of permissions a site defines for its users (rows come from the application's migrations or seeds). A user's own choices are stored in `users.permissions` and a group's in `user_groups.permissions`, as a JSON object of code to `1` (allow) or `-1` (deny). `MergedPermissions` combines them as PHP `getMergedPermissions` does: among a user's groups the most positive value wins, then the user's own value replaces the groups'. `HasPermission` and `UserHasPermission` answer whether a code is held: only at exactly `1`, with Winter's `*` wildcards. Neither value appears in the user payload, whose `permissions` key stays `null`.
|
||||
- **Last seen.** `users.last_seen` is stamped by `POST /login` and `POST /refresh` through `TouchLastSeen`, at most once per five minutes per user. A failed write is logged and never fails authentication. It is shown in the admin list and is not part of any user payload.
|
||||
- **Handle.** `users.handle` is a unique nullable `VARCHAR(255)` generated on create (`classes.HandleGenerator`, charset `[a-z0-9._-]`, length 2–255). It is omitted from the user JSON payload (`json:"-"`). `user:backfill-handles` fills existing rows that still have none.
|
||||
- **User lighthouse authorizer.** Boot calls `classes.RegisterUserAuthorizer`: `lighthouse.From` then `Registry.Register("user", …)` if the namespace is free. `classes.AuthorizeUser` allows only `user:{id}` for that user (`lighthouse.ChannelID` on the two-segment name).
|
||||
- **Shared API tokens.** `user_api_tokens` stores one hashed personal/OAuth credential per user for the whole application. The `user.api_token` guard authenticates it for any plugin; `user.scope:<scope>` limits delegated access and `user.permission:<code>` checks the user's current group permissions. `POST/GET/DELETE /tokens` manage personal tokens, and `user:token:create`, `user:token:list` and `user:token:revoke` provide the CLI equivalent. A raw secret is shown only when created.
|
||||
- **Mail templates.** Activation, reset, reactivation and invitation mails in Polish and English (`golem15.user::mail.*`, layout `golem15.user::mail.layouts.user`); the locale follows the user (`ResolveMailLocale`). The invitation (`golem15.user::mail.invite`) is sent when an administrator creates a user with "Send invitation by email" set; it carries the user's login and an activation link, never the password.
|
||||
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `GetApiArrayEvent` lets other plugins merge keys into the user payload.
|
||||
- **Extension events.** `RegisterEvent` fires after a user row is created; its `Payload` is a copy of the register input (extra keys a client sent included, `password` and `password_confirmation` never). `CreatedEvent` (`classes.CreatedEventName` `user.Created`) fires from API register and admin create. `DeletingEvent` (`classes.DeletingEventName` `user.Deleting`) fires from `FormAfterDelete` before `ForceDeleteCleanup` / unscoped delete, inside the same transaction. `GetApiArrayEvent` lets other plugins merge keys into the user payload.
|
||||
- **Registration for other plugins.** `RegisterUser`, `FireRegisterEvent`, `IssueToken` and `APIArray` are the steps `POST /register` runs, exported so another plugin (a first-run setup endpoint, for example) creates a user with the same validation, hashing, event, token and payload.
|
||||
- **OAuth providers.** `GET /oauth-providers` lists configured social login providers (none yet: it returns an empty list).
|
||||
|
||||
@@ -149,11 +151,19 @@ Migrations added for linked OAuth identities, all additive:
|
||||
|----|------|
|
||||
| `202610050001_create_oauth_identities` | the table `golem15_user_oauth_identities` (`user_id` cascade FK to `users`, unique `(user_id, provider)` and `(provider, provider_id)`, jsonb `profile_data`) |
|
||||
|
||||
Migrations added for @mention handles, all additive:
|
||||
|
||||
| ID | Adds |
|
||||
|----|------|
|
||||
| `202610160002_add_handle_to_users` | `users.handle` (`VARCHAR(255)` NULL, unique index `users_handle_unique`, `ADD COLUMN IF NOT EXISTS`; rollback is a no-op) |
|
||||
|
||||
Packages:
|
||||
|
||||
- `controllers`: the route handlers above, `AdminControllers` (the admin controller list), `PermissionAccessUsers` and `PermissionAccessGroups`, plus the registration steps `POST /register` runs, for other plugins:
|
||||
- `RegisterUser(ctx, app, db, fields, RegisterOptions)` validates `fields` against the user model's rules, hashes the password with `golem15.user.password.bcrypt_cost` and creates the user on `db` (which may be a transaction). It returns the validation errors instead of writing a response and never modifies `fields`. `RegisterOptions.Activate` creates the account activated; `RegisterOptions.IP` records the client IP.
|
||||
- `FireRegisterEvent(ctx, app, user, fields)` fires `RegisterEvent` with `Payload` set to a copy of `fields` without `password` and `password_confirmation`, and returns the listeners' error (`/register` ignores it). Fire it after the user row is committed.
|
||||
- `FireCreatedEvent(ctx, app, user)` fires `CreatedEvent` (`user.Created`). `/register` ignores that error.
|
||||
- `FireDeletingEvent(ctx, app, user, tx)` fires `DeletingEvent` (`user.Deleting`) inside the delete transaction.
|
||||
- `IssueToken(app, user, r)` mints the session JWT as `/register` and `/login` do.
|
||||
- `APIArray(ctx, app, user)` builds the user payload, extended by `GetApiArrayEvent` listeners.
|
||||
- `classes`: `JWTSecret`, `GormUsers` (the `bouncer.UserProvider`), `LookupByEmail`, `UserPermissionGrants`, `UserGroupCodes`, `HasGroupCode`, `APITokenPrefix`, `MintAPIToken`, `HashAPIToken` and `RevokeAPIToken`, plus the activation/reset, throttle, mail locale and event helpers. `RegisterEvent` carries `User` and `Payload map[string]any`, a read-only copy of the register input without the password keys. For the admin and for other plugins it also has:
|
||||
@@ -161,10 +171,11 @@ Packages:
|
||||
- `PermissionManagePrivilegedGroups`, `PrivilegedGroupCodes(cfg)`, `IsPrivilegedCode(codes, code)`, `PrivilegedGroupIDs(ctx, db, codes)` and `IsPrivilegedMember(ctx, db, codes, userID)`, the privileged-group rule.
|
||||
- `ActivateUsers`, `DeactivateUsers`, `RestoreUsers`, `BanUsers`, `UnbanUsers`, `UnsuspendUser`, `ThrottleStates` and `ForceDeleteCleanup`, the user actions the admin runs. Each takes the handle of the caller's transaction.
|
||||
- `TouchLastSeen(ctx, db, userID)`.
|
||||
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
|
||||
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
|
||||
- `models`: `User`, `Throttle`, `Organisation`, `UserGroup`, `UsersGroup` (the `users_groups` pivot), `APIToken`, `OAuthIdentity`, `FrontendPermission` and `PermissionSet`. `PermissionSet` reads a stored permission column tolerantly (NULL, an empty string, `[]`, numeric strings) and writes a JSON object of integers; `ParsePermissionSet` and `PermissionSet.Text` do the same for a column that is a plain string on its model. `UserGroup.UsersCount` is filled only by a query that selects `users_count` (the admin list does). `Organisation.Members` is the relation behind the members manager, `Organisation.AttachRelations` declares its avatar (`OrganisationAvatarField`), and `Slugify` is the slug rule. `User.FilterScope` answers the admin list scope `FilterByGroup`, and `User.AttachRelations` declares the avatar (`AvatarField`).
|
||||
- `updates`: the gormigrate migrations, returned by `updates.All()`.
|
||||
- `console`: `RequirePasswordChange` and `APITokenCommands`.
|
||||
- `console`: `RequirePasswordChange`, `BackfillHandles` and `APITokenCommands`.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -197,6 +208,7 @@ Defaults live in `config/config.yaml` and are merged under `golem15.user.*`. Any
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `user:require-password-change <email>` | Flag an account so its next session must set a new password. |
|
||||
| `user:backfill-handles` | Assign a unique handle to every user row that still has none. |
|
||||
| `user:token:create <email> [--name api] [--scopes read,write] [--expires RFC3339]` | Create an application-wide API token and print its secret once. |
|
||||
| `user:token:list` | List token metadata without secrets or hashes. |
|
||||
| `user:token:revoke <id>` | Revoke a shared API token immediately. |
|
||||
|
||||
49
classes/authorizer.go
Normal file
49
classes/authorizer.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||
)
|
||||
|
||||
// AuthorizeUser is the lighthouse authorizer for the user namespace
|
||||
// (D-17, T-19-10). It allows only user:{id} for that user. ChannelID is
|
||||
// the two-segment reader (segment 1 is the id). A second presence: prefix
|
||||
// or extra segments are denied.
|
||||
func AuthorizeUser() lighthouse.AuthorizerFunc {
|
||||
return func(_ context.Context, userID uint, channel string) lighthouse.Result {
|
||||
name := strings.TrimPrefix(channel, "presence:")
|
||||
if strings.HasPrefix(name, "presence:") {
|
||||
return lighthouse.Denied("invalid user channel")
|
||||
}
|
||||
parts := strings.Split(name, ":")
|
||||
if len(parts) != 2 || parts[0] != "user" {
|
||||
return lighthouse.Denied("invalid user channel")
|
||||
}
|
||||
if userID == 0 || lighthouse.ChannelID(name) != int64(userID) {
|
||||
return lighthouse.Denied("not that user")
|
||||
}
|
||||
return lighthouse.Allowed(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterUserAuthorizer registers the user namespace if it is free. A
|
||||
// missing lighthouse service is skipped so Boot stays additive on hosts
|
||||
// that never mount realtime.
|
||||
func RegisterUserAuthorizer(app *backpack.App) error {
|
||||
svc, err := lighthouse.From(app)
|
||||
if err != nil {
|
||||
slog.Default().Warn("golem15.user: lighthouse unavailable; user authorizer not registered", "err", err)
|
||||
return nil
|
||||
}
|
||||
if svc == nil || svc.Registry() == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := svc.Registry().Get("user"); ok {
|
||||
return nil
|
||||
}
|
||||
return svc.Registry().Register("user", AuthorizeUser())
|
||||
}
|
||||
36
classes/authorizer_test.go
Normal file
36
classes/authorizer_test.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
||||
)
|
||||
|
||||
// TestUserAuthorizer: user:9 is allowed only for user 9 (T-19-10).
|
||||
func TestUserAuthorizer(t *testing.T) {
|
||||
auth := AuthorizeUser()
|
||||
ctx := context.Background()
|
||||
|
||||
if got := auth.Authorize(ctx, 9, "user:9"); !got.Allowed {
|
||||
t.Fatalf("user:9 for 9 denied: %s", got.Reason())
|
||||
}
|
||||
if got := auth.Authorize(ctx, 9, "presence:user:9"); !got.Allowed {
|
||||
t.Fatalf("presence:user:9 for 9 denied: %s", got.Reason())
|
||||
}
|
||||
if lighthouse.ChannelID("user:9") != 9 {
|
||||
t.Fatalf("ChannelID(user:9) = %d, want 9", lighthouse.ChannelID("user:9"))
|
||||
}
|
||||
|
||||
for _, channel := range []string{"user:8", "user:9:extra", "chat:9", "user:", "user", "presence:presence:user:9"} {
|
||||
if got := auth.Authorize(ctx, 9, channel); got.Allowed {
|
||||
t.Errorf("%q for 9 allowed, want deny", channel)
|
||||
}
|
||||
}
|
||||
if got := auth.Authorize(ctx, 8, "user:9"); got.Allowed {
|
||||
t.Fatal("user:9 for 8 allowed, want deny")
|
||||
}
|
||||
if got := auth.Authorize(ctx, 0, "user:0"); got.Allowed {
|
||||
t.Fatal("user:0 for 0 allowed, want deny")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
// CreatedEventName is the festival name of CreatedEvent (D-13 / RESEARCH A6).
|
||||
const CreatedEventName = "user.Created"
|
||||
|
||||
// DeletingEventName is the festival name of DeletingEvent (D-13 / RESEARCH A6).
|
||||
const DeletingEventName = "user.Deleting"
|
||||
|
||||
// GetApiArrayEvent is the fire-and-collect payload other plugins extend.
|
||||
type GetApiArrayEvent struct {
|
||||
User *models.User
|
||||
@@ -41,3 +44,11 @@ type CreatedEvent struct {
|
||||
User *models.User
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
// DeletingEvent fires from FormAfterDelete before ForceDeleteCleanup /
|
||||
// unscoped delete, inside the same transaction, so listeners (chatchannels
|
||||
// GDPR) can sever rows before the user row is gone (D-13, T-19-07).
|
||||
type DeletingEvent struct {
|
||||
User *models.User
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
@@ -65,3 +65,22 @@ func TestCreatedEventFires(t *testing.T) {
|
||||
t.Fatalf("created event user = %#v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletingEventFires(t *testing.T) {
|
||||
if DeletingEventName != "user.Deleting" {
|
||||
t.Fatalf("DeletingEventName = %q, want user.Deleting", DeletingEventName)
|
||||
}
|
||||
bus := festival.New()
|
||||
var seen *models.User
|
||||
bus.Listen[*DeletingEvent]("golem15.user", func(_ context.Context, e *DeletingEvent) error {
|
||||
seen = e.User
|
||||
return nil
|
||||
})
|
||||
user := &models.User{ID: 9, Email: "gone@example.test"}
|
||||
if err := bus.Fire(context.Background(), &DeletingEvent{User: user}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen == nil || seen.Email != user.Email {
|
||||
t.Fatalf("deleting event user = %#v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
42
console/backfill_handles.go
Normal file
42
console/backfill_handles.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BackfillHandles fills a unique handle on every user row that still has none.
|
||||
func BackfillHandles(app *backpack.App) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "user:backfill-handles",
|
||||
Description: "Assign unique handles to users that do not have one (D-15)",
|
||||
Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error {
|
||||
if app == nil {
|
||||
return fmt.Errorf("user:backfill-handles: app is nil")
|
||||
}
|
||||
gdb, ok := app.Lookup[*gorm.DB]()
|
||||
if !ok || gdb == nil {
|
||||
return fmt.Errorf("user:backfill-handles: database is not configured")
|
||||
}
|
||||
var users []models.User
|
||||
if err := gdb.WithContext(ctx).Where("handle IS NULL OR handle = ''").Find(&users).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
n := 0
|
||||
for i := range users {
|
||||
if err := classes.AssignHandle(ctx, gdb, &users[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
}
|
||||
out.Success(fmt.Sprintf("Assigned handles to %d user(s)", n))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,16 @@ func FireCreatedEvent(ctx context.Context, app *backpack.App, user *models.User)
|
||||
return app.Events.Fire(ctx, &classes.CreatedEvent{User: user})
|
||||
}
|
||||
|
||||
// FireDeletingEvent fires classes.DeletingEvent (user.Deleting) for user
|
||||
// inside the delete transaction. FormAfterDelete returns the listeners'
|
||||
// error so a failed cascade rolls the delete back.
|
||||
func FireDeletingEvent(ctx context.Context, app *backpack.App, user *models.User, tx *gorm.DB) error {
|
||||
if app == nil || app.Events == nil {
|
||||
return nil
|
||||
}
|
||||
return app.Events.Fire(ctx, &classes.DeletingEvent{User: user, Tx: tx})
|
||||
}
|
||||
|
||||
// IssueToken mints the session JWT for user exactly as /register and
|
||||
// /login do: the plugin's JWT secret, golem15.user.jwt.ttl and the request
|
||||
// URL as the issuer.
|
||||
|
||||
@@ -522,6 +522,9 @@ func (c usersAdminController) FormAfterDelete(ctx context.Context, model any) er
|
||||
if !ok {
|
||||
return errors.New("users admin: a delete needs its transaction")
|
||||
}
|
||||
if err := FireDeletingEvent(ctx, c.application(), user, tx); err != nil {
|
||||
return fmt.Errorf("%s: %w", classes.DeletingEventName, err)
|
||||
}
|
||||
if err := classes.ForceDeleteCleanup(ctx, tx, c.bucket(), user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -103,10 +103,13 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
||||
return err
|
||||
}
|
||||
trusted := surf.TrustedProxies(app.Config)
|
||||
return reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{
|
||||
if err := reg.Register(p.ID(), "api_token", &usermiddleware.APITokenGuard{
|
||||
App: app, Prefix: prefix,
|
||||
ClientIP: func(r *http.Request) string { return surf.ClientIP(r, trusted) },
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return classes.RegisterUserAuthorizer(app)
|
||||
}
|
||||
|
||||
func (p *Plugin) startBlacklistSweep(bl bouncer.BlacklistStore) {
|
||||
@@ -201,7 +204,7 @@ func (p *Plugin) MailLayouts() map[string]string {
|
||||
}
|
||||
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return append([]bonfire.Command{console.RequirePasswordChange(p.app)}, console.APITokenCommands(p.app)...)
|
||||
return append([]bonfire.Command{console.RequirePasswordChange(p.app), console.BackfillHandles(p.app)}, console.APITokenCommands(p.app)...)
|
||||
}
|
||||
|
||||
func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
|
||||
|
||||
Reference in New Issue
Block a user