fix: deleting an organisation removes its avatar and logo attachments
The Organisations screen's delete left the organisation's system_files rows and blobs behind. FormBeforeDelete now deletes them in the delete's transaction (attach.DeleteForOwner) and removes the blobs once it commits, as the user delete does.
This commit is contained in:
@@ -28,7 +28,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
|
||||
- **User groups.** The `user_groups` table (seeded with the `guest` and `registered` groups, as in PHP) and the `users_groups` membership pivot, the `UserGroup` model and the `User.Groups` relation, which is never serialized. `UserGroupCodes` and `HasGroupCode` let other plugins check membership server-side, for example a site-admin check on the group code `admin`. No user API route reads or writes groups, and the user payload still answers `"groups":[]`. Membership is written in one place only: the `groups` field of the admin user form. Apart from that, rows leave `users_groups` only when a user or a group is deleted for good. The admin Users list can filter by group.
|
||||
- **Admin screens.** The plugin registers the Users screen with the framework's admin (`pact.HasAdminControllers`, controller ID `golem15.user.users`), the port of the WinterCMS `Users` controller: a list with search, the group, registration date and activated filters and row states (deactivated, banned, not activated); a read-only preview with a status hint; and a form with name, email, a password with confirmation, an optional invitation mail, the user's groups, the organisation, the avatar and the user's frontend permissions. Bulk actions activate, deactivate, restore, ban and unban users; the preview offers activate, unban and unsuspend while they apply. Deactivating is the soft delete, and deactivate and ban both end the user's earlier sessions and personal API tokens (see Deactivation and bans); the delete button and the bulk delete remove a user for good, together with the throttle rows, group memberships and attachments. A user created in the admin starts not activated. There is no impersonation and no guest account.
|
||||
- **User Groups screen.** Controller ID `golem15.user.usergroups`, the port of the WinterCMS `UserGroups` controller, behind `golem15.users.access_groups`: a list with the name, code, creation date and number of members, and a form with the name, the code (letters, digits, underscore and hyphen; unique), a description and the group's frontend permissions as checkboxes, stored in `user_groups.permissions` as a JSON object of code to `1`. A group can be deleted from its form; its memberships go with it and its members stay. The screen never changes who is in a group.
|
||||
- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description, avatar and logo (an image upload), and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; both also clear the user's `organisation_role`, so a role never moves to another organisation or outlives the membership. A user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form, where a save that changes the organisation, to another one or to none, clears `organisation_role` unless the same save writes it). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first.
|
||||
- **Organisations screen.** Controller ID `golem15.user.organisations`, the port of the WinterCMS `Organisations` controller, behind `golem15.users.access_users`: a list and a form with the name, slug, description, avatar and logo (an image upload), and on an existing organisation the Members tab. Adding a member sets that user's `organisation_id` and removing one clears it; both also clear the user's `organisation_role`, so a role never moves to another organisation or outlives the membership. A user is never deleted. Only users without an organisation can be added, so a user moves by being removed from one organisation and added to the other (or through the organisation field of the user form, where a save that changes the organisation, to another one or to none, clears `organisation_role` unless the same save writes it). Deleting an organisation clears `organisation_id` and `organisation_role` of its members first, and removes its avatar and logo attachments with it (the files once the delete commits).
|
||||
- **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 a successful `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.
|
||||
|
||||
@@ -4,9 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -105,8 +109,10 @@ func (organisationsAdminController) RelationExtendManageQuery(_ context.Context,
|
||||
// FormBeforeDelete releases the members of the organisation in the delete's
|
||||
// transaction: organisation_id and organisation_role are cleared, on
|
||||
// deactivated users too, so the delete does not fail on the users foreign key
|
||||
// and no user is deleted.
|
||||
func (organisationsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
|
||||
// and no user is deleted. The organisation's attachments (avatar and logo)
|
||||
// are deleted in the same transaction, and their blobs once it commits, so
|
||||
// no system_files row outlives the organisation row.
|
||||
func (c organisationsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
|
||||
organisation, ok := model.(*models.Organisation)
|
||||
if !ok || organisation == nil {
|
||||
return errors.New("organisations admin: form model is not an organisation")
|
||||
@@ -115,7 +121,42 @@ func (organisationsAdminController) FormBeforeDelete(ctx context.Context, model
|
||||
if !ok {
|
||||
return errors.New("organisations admin: a delete needs its transaction")
|
||||
}
|
||||
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||
if err := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
||||
Model(&models.User{}).Where("organisation_id = ?", organisation.ID).
|
||||
Updates(map[string]any{"organisation_id": nil, "organisation_role": nil}).Error
|
||||
Updates(map[string]any{"organisation_id": nil, "organisation_role": nil}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var keys []string
|
||||
if err := attach.DeleteForOwner(tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx), organisation, strconv.FormatUint(uint64(organisation.ID), 10), func(blobKeys []string) error {
|
||||
keys = append(keys, blobKeys...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
bucket := c.bucket()
|
||||
if bucket == nil || len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
id := organisation.ID
|
||||
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
||||
// The rows are gone by now, so a blob that cannot be removed is
|
||||
// orphaned, not lost data: log it and carry on.
|
||||
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
|
||||
pluginLog.Warn("golem15.user: remove attachment blobs of a deleted organisation", "organisation_id", id, "err", err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucket is the application's blob bucket, or nil when none is published.
|
||||
func (c organisationsAdminController) bucket() *blob.Bucket {
|
||||
app := c.app.application()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
bucket, ok := app.Lookup[*blob.Bucket]()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return bucket
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user