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:
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user