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:
Jakub Zych
2026-10-17 21:53:21 +02:00
parent 15b19c7021
commit c0770294a5
9 changed files with 190 additions and 5 deletions

49
classes/authorizer.go Normal file
View 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())
}

View 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")
}
}

View File

@@ -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
}

View File

@@ -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)
}
}