From aaca8c05aa7c575adada93e7bc21303ec99db6f5 Mon Sep 17 00:00:00 2001 From: Jacob See <5027680+jacobsee@users.noreply.github.com> Date: Tue, 19 Sep 2023 11:47:09 -0700 Subject: [PATCH] initial API implementation of admin elevation request and expiration (#56) * initial implementation of admin elevation request and expiration (to be handled by core addon) * add wildcard requets subject for clients to use * admin expiry permission check, new audit actions * fix lint * promotion terminology, no wildcard in event subject * revert to one event subject --- db/migrations/00033_group_request_types.sql | 15 + internal/dbtools/errors.go | 6 + internal/dbtools/hooks.go | 56 ++- internal/dbtools/membership_enumeration.go | 34 +- internal/models/group_membership_requests.go | 130 +++---- internal/models/group_memberships.go | 109 +++--- pkg/api/v1alpha1/auth.go | 9 +- pkg/api/v1alpha1/authenticated_user.go | 2 + pkg/api/v1alpha1/errors.go | 2 + pkg/api/v1alpha1/group_membership.go | 345 ++++++++++++------- pkg/client/groups.go | 40 +++ 11 files changed, 500 insertions(+), 248 deletions(-) create mode 100644 db/migrations/00033_group_request_types.sql create mode 100644 internal/dbtools/errors.go diff --git a/db/migrations/00033_group_request_types.sql b/db/migrations/00033_group_request_types.sql new file mode 100644 index 0000000..a2e60c7 --- /dev/null +++ b/db/migrations/00033_group_request_types.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TYPE request_kind AS ENUM ('new_member', 'admin_promotion'); +ALTER TABLE group_membership_requests ADD COLUMN IF NOT EXISTS kind request_kind NOT NULL DEFAULT 'new_member'; +ALTER TABLE group_memberships ADD COLUMN IF NOT EXISTS admin_expires_at TIMESTAMPTZ NULL; +ALTER TABLE group_membership_requests ADD COLUMN IF NOT EXISTS admin_expires_at TIMESTAMPTZ NULL; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE group_membership_requests DROP COLUMN IF EXISTS admin_expires_at; +ALTER TABLE group_memberships DROP COLUMN IF EXISTS admin_expires_at; +ALTER TABLE group_membership_requests DROP COLUMN kind; +DROP TYPE request_kind; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/dbtools/errors.go b/internal/dbtools/errors.go new file mode 100644 index 0000000..75901c2 --- /dev/null +++ b/internal/dbtools/errors.go @@ -0,0 +1,6 @@ +package dbtools + +import "errors" + +// ErrUnknownRequestKind is returned a request kind is unknown +var ErrUnknownRequestKind = errors.New("request kind is unrecognized") diff --git a/internal/dbtools/hooks.go b/internal/dbtools/hooks.go index 60fe246..d9396d4 100644 --- a/internal/dbtools/hooks.go +++ b/internal/dbtools/hooks.go @@ -392,7 +392,7 @@ func AuditGroupMemberPromoted(ctx context.Context, exec boil.ContextExecutor, pI } // AuditGroupMembershipApproved inserts an event representing group membership approval into the events table -func AuditGroupMembershipApproved(ctx context.Context, exec boil.ContextExecutor, pID string, actor *models.User, m *models.GroupMembership) ([]*models.AuditEvent, error) { +func AuditGroupMembershipApproved(ctx context.Context, exec boil.ContextExecutor, pID string, actor *models.User, m *models.GroupMembership, kind string) ([]*models.AuditEvent, error) { // TODO non-user API actors don't exist in the governor database, // we need to figure out how to handle that relationship in the audit table var actorID null.String @@ -400,12 +400,23 @@ func AuditGroupMembershipApproved(ctx context.Context, exec boil.ContextExecutor actorID = null.StringFrom(actor.ID) } + var action string + + switch kind { + case "new_member": + action = "group.member.request.approved" + case "admin_promotion": + action = "admin.promotion.request.approved" + default: + return nil, ErrUnknownRequestKind + } + event := models.AuditEvent{ ParentID: null.StringFrom(pID), ActorID: actorID, SubjectGroupID: null.StringFrom(m.GroupID), SubjectUserID: null.StringFrom(m.UserID), - Action: "group.member.request.approved", + Action: action, Changeset: calculateGroupMembershipChangeset(&models.GroupMembership{}, m), Message: "Request was approved.", } @@ -431,12 +442,23 @@ func AuditGroupMembershipRevoked(ctx context.Context, exec boil.ContextExecutor, actorID = null.StringFrom(actor.ID) } + var action string + + switch r.Kind { + case "new_member": + action = "group.member.request.revoked" + case "admin_promotion": + action = "admin.promotion.request.revoked" + default: + return nil, ErrUnknownRequestKind + } + event := models.AuditEvent{ ParentID: null.StringFrom(pID), ActorID: actorID, SubjectGroupID: null.StringFrom(r.GroupID), SubjectUserID: null.StringFrom(r.UserID), - Action: "group.member.request.revoked", + Action: action, Changeset: []string{}, Message: "Request was revoked.", } @@ -453,12 +475,23 @@ func AuditGroupMembershipDenied(ctx context.Context, exec boil.ContextExecutor, actorID = null.StringFrom(actor.ID) } + var action string + + switch r.Kind { + case "new_member": + action = "group.member.request.denied" + case "admin_promotion": + action = "admin.promotion.request.denied" + default: + return nil, ErrUnknownRequestKind + } + event := models.AuditEvent{ ParentID: null.StringFrom(pID), ActorID: actorID, SubjectGroupID: null.StringFrom(r.GroupID), SubjectUserID: null.StringFrom(r.UserID), - Action: "group.member.request.denied", + Action: action, Changeset: []string{}, Message: "Request was denied.", } @@ -475,14 +508,25 @@ func AuditGroupMembershipRequestCreated(ctx context.Context, exec boil.ContextEx actorID = null.StringFrom(actor.ID) } + var action string + + switch r.Kind { + case "new_member": + action = "group.member.request.created" + case "admin_promotion": + action = "admin.promotion.request.created" + default: + return nil, ErrUnknownRequestKind + } + event := models.AuditEvent{ ParentID: null.StringFrom(pID), ActorID: actorID, SubjectGroupID: null.StringFrom(r.GroupID), SubjectUserID: null.StringFrom(r.UserID), - Action: "group.member.request.created", + Action: action, Changeset: []string{}, - Message: "User requested to join group.", + Message: "Request was created.", } return &event, event.Insert(ctx, exec, boil.Infer()) diff --git a/internal/dbtools/membership_enumeration.go b/internal/dbtools/membership_enumeration.go index a9544d5..9407a15 100644 --- a/internal/dbtools/membership_enumeration.go +++ b/internal/dbtools/membership_enumeration.go @@ -41,6 +41,7 @@ const ( group_id, user_id, expires_at, + admin_expires_at, is_admin, TRUE AS direct FROM @@ -50,6 +51,7 @@ const ( b.parent_group_id, a.user_id, b.expires_at, + b.admin_expires_at, FALSE AS is_admin, FALSE AS direct FROM @@ -64,6 +66,11 @@ const ( ELSE NULL END AS expires_at, + CASE WHEN BOOL_OR(direct) THEN + MAX(admin_expires_at) + ELSE + NULL + END AS admin_expires_at, BOOL_OR(is_admin) as is_admin, BOOL_OR(direct) as direct FROM @@ -77,6 +84,7 @@ const ( user_id, is_admin, expires_at, + admin_expires_at, TRUE AS direct FROM group_memberships @@ -87,6 +95,7 @@ const ( a.user_id, FALSE AS is_admin, NULL as expires_at, + NULL as admin_expires_at, FALSE AS direct FROM membership_query AS a @@ -100,6 +109,11 @@ const ( ELSE NULL END AS expires_at, + CASE WHEN BOOL_OR(direct) THEN + MAX(admin_expires_at) + ELSE + NULL + END AS admin_expires_at, BOOL_OR(is_admin) as is_admin, BOOL_OR(direct) as direct FROM @@ -149,6 +163,11 @@ const ( ELSE NULL END AS expires_at, + CASE WHEN BOOL_OR(direct) THEN + MAX(group_memberships.admin_expires_at) + ELSE + NULL + END AS admin_expires_at, BOOL_OR(direct) as direct FROM ensure_root @@ -159,13 +178,14 @@ const ( // EnumeratedMembership represents a single user-to-group membership, which may be direct or indirect type EnumeratedMembership struct { - GroupID string - Group *models.Group - UserID string - User *models.User - IsAdmin bool - ExpiresAt null.Time - Direct bool + GroupID string + Group *models.Group + UserID string + User *models.User + IsAdmin bool + ExpiresAt null.Time + AdminExpiresAt null.Time + Direct bool } // GetMembershipsForUser returns a fully enumerated list of memberships for a user, optionally with sqlboiler's generated models populated diff --git a/internal/models/group_membership_requests.go b/internal/models/group_membership_requests.go index 9b0457a..93b7f49 100644 --- a/internal/models/group_membership_requests.go +++ b/internal/models/group_membership_requests.go @@ -24,57 +24,67 @@ import ( // GroupMembershipRequest is an object representing the database table. type GroupMembershipRequest struct { - ID string `boil:"id" json:"id" toml:"id" yaml:"id"` - GroupID string `boil:"group_id" json:"group_id" toml:"group_id" yaml:"group_id"` - UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` - CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` - UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` - IsAdmin bool `boil:"is_admin" json:"is_admin" toml:"is_admin" yaml:"is_admin"` - Note string `boil:"note" json:"note" toml:"note" yaml:"note"` - ExpiresAt null.Time `boil:"expires_at" json:"expires_at,omitempty" toml:"expires_at" yaml:"expires_at,omitempty"` + ID string `boil:"id" json:"id" toml:"id" yaml:"id"` + GroupID string `boil:"group_id" json:"group_id" toml:"group_id" yaml:"group_id"` + UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + IsAdmin bool `boil:"is_admin" json:"is_admin" toml:"is_admin" yaml:"is_admin"` + Note string `boil:"note" json:"note" toml:"note" yaml:"note"` + ExpiresAt null.Time `boil:"expires_at" json:"expires_at,omitempty" toml:"expires_at" yaml:"expires_at,omitempty"` + Kind string `boil:"kind" json:"kind" toml:"kind" yaml:"kind"` + AdminExpiresAt null.Time `boil:"admin_expires_at" json:"admin_expires_at,omitempty" toml:"admin_expires_at" yaml:"admin_expires_at,omitempty"` R *groupMembershipRequestR `boil:"-" json:"-" toml:"-" yaml:"-"` L groupMembershipRequestL `boil:"-" json:"-" toml:"-" yaml:"-"` } var GroupMembershipRequestColumns = struct { - ID string - GroupID string - UserID string - CreatedAt string - UpdatedAt string - IsAdmin string - Note string - ExpiresAt string + ID string + GroupID string + UserID string + CreatedAt string + UpdatedAt string + IsAdmin string + Note string + ExpiresAt string + Kind string + AdminExpiresAt string }{ - ID: "id", - GroupID: "group_id", - UserID: "user_id", - CreatedAt: "created_at", - UpdatedAt: "updated_at", - IsAdmin: "is_admin", - Note: "note", - ExpiresAt: "expires_at", + ID: "id", + GroupID: "group_id", + UserID: "user_id", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + IsAdmin: "is_admin", + Note: "note", + ExpiresAt: "expires_at", + Kind: "kind", + AdminExpiresAt: "admin_expires_at", } var GroupMembershipRequestTableColumns = struct { - ID string - GroupID string - UserID string - CreatedAt string - UpdatedAt string - IsAdmin string - Note string - ExpiresAt string + ID string + GroupID string + UserID string + CreatedAt string + UpdatedAt string + IsAdmin string + Note string + ExpiresAt string + Kind string + AdminExpiresAt string }{ - ID: "group_membership_requests.id", - GroupID: "group_membership_requests.group_id", - UserID: "group_membership_requests.user_id", - CreatedAt: "group_membership_requests.created_at", - UpdatedAt: "group_membership_requests.updated_at", - IsAdmin: "group_membership_requests.is_admin", - Note: "group_membership_requests.note", - ExpiresAt: "group_membership_requests.expires_at", + ID: "group_membership_requests.id", + GroupID: "group_membership_requests.group_id", + UserID: "group_membership_requests.user_id", + CreatedAt: "group_membership_requests.created_at", + UpdatedAt: "group_membership_requests.updated_at", + IsAdmin: "group_membership_requests.is_admin", + Note: "group_membership_requests.note", + ExpiresAt: "group_membership_requests.expires_at", + Kind: "group_membership_requests.kind", + AdminExpiresAt: "group_membership_requests.admin_expires_at", } // Generated where @@ -89,23 +99,27 @@ func (w whereHelperbool) GT(x bool) qm.QueryMod { return qmhelper.Where(w.field func (w whereHelperbool) GTE(x bool) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) } var GroupMembershipRequestWhere = struct { - ID whereHelperstring - GroupID whereHelperstring - UserID whereHelperstring - CreatedAt whereHelpertime_Time - UpdatedAt whereHelpertime_Time - IsAdmin whereHelperbool - Note whereHelperstring - ExpiresAt whereHelpernull_Time + ID whereHelperstring + GroupID whereHelperstring + UserID whereHelperstring + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time + IsAdmin whereHelperbool + Note whereHelperstring + ExpiresAt whereHelpernull_Time + Kind whereHelperstring + AdminExpiresAt whereHelpernull_Time }{ - ID: whereHelperstring{field: "\"group_membership_requests\".\"id\""}, - GroupID: whereHelperstring{field: "\"group_membership_requests\".\"group_id\""}, - UserID: whereHelperstring{field: "\"group_membership_requests\".\"user_id\""}, - CreatedAt: whereHelpertime_Time{field: "\"group_membership_requests\".\"created_at\""}, - UpdatedAt: whereHelpertime_Time{field: "\"group_membership_requests\".\"updated_at\""}, - IsAdmin: whereHelperbool{field: "\"group_membership_requests\".\"is_admin\""}, - Note: whereHelperstring{field: "\"group_membership_requests\".\"note\""}, - ExpiresAt: whereHelpernull_Time{field: "\"group_membership_requests\".\"expires_at\""}, + ID: whereHelperstring{field: "\"group_membership_requests\".\"id\""}, + GroupID: whereHelperstring{field: "\"group_membership_requests\".\"group_id\""}, + UserID: whereHelperstring{field: "\"group_membership_requests\".\"user_id\""}, + CreatedAt: whereHelpertime_Time{field: "\"group_membership_requests\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"group_membership_requests\".\"updated_at\""}, + IsAdmin: whereHelperbool{field: "\"group_membership_requests\".\"is_admin\""}, + Note: whereHelperstring{field: "\"group_membership_requests\".\"note\""}, + ExpiresAt: whereHelpernull_Time{field: "\"group_membership_requests\".\"expires_at\""}, + Kind: whereHelperstring{field: "\"group_membership_requests\".\"kind\""}, + AdminExpiresAt: whereHelpernull_Time{field: "\"group_membership_requests\".\"admin_expires_at\""}, } // GroupMembershipRequestRels is where relationship names are stored. @@ -146,9 +160,9 @@ func (r *groupMembershipRequestR) GetGroup() *Group { type groupMembershipRequestL struct{} var ( - groupMembershipRequestAllColumns = []string{"id", "group_id", "user_id", "created_at", "updated_at", "is_admin", "note", "expires_at"} + groupMembershipRequestAllColumns = []string{"id", "group_id", "user_id", "created_at", "updated_at", "is_admin", "note", "expires_at", "kind", "admin_expires_at"} groupMembershipRequestColumnsWithoutDefault = []string{"group_id", "user_id", "created_at", "updated_at"} - groupMembershipRequestColumnsWithDefault = []string{"id", "is_admin", "note", "expires_at"} + groupMembershipRequestColumnsWithDefault = []string{"id", "is_admin", "note", "expires_at", "kind", "admin_expires_at"} groupMembershipRequestPrimaryKeyColumns = []string{"id"} groupMembershipRequestGeneratedColumns = []string{} ) diff --git a/internal/models/group_memberships.go b/internal/models/group_memberships.go index d68ffe2..a4d65be 100644 --- a/internal/models/group_memberships.go +++ b/internal/models/group_memberships.go @@ -24,72 +24,79 @@ import ( // GroupMembership is an object representing the database table. type GroupMembership struct { - ID string `boil:"id" json:"id" toml:"id" yaml:"id"` - GroupID string `boil:"group_id" json:"group_id" toml:"group_id" yaml:"group_id"` - UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` - IsAdmin bool `boil:"is_admin" json:"is_admin" toml:"is_admin" yaml:"is_admin"` - CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` - UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` - ExpiresAt null.Time `boil:"expires_at" json:"expires_at,omitempty" toml:"expires_at" yaml:"expires_at,omitempty"` + ID string `boil:"id" json:"id" toml:"id" yaml:"id"` + GroupID string `boil:"group_id" json:"group_id" toml:"group_id" yaml:"group_id"` + UserID string `boil:"user_id" json:"user_id" toml:"user_id" yaml:"user_id"` + IsAdmin bool `boil:"is_admin" json:"is_admin" toml:"is_admin" yaml:"is_admin"` + CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"` + UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"` + ExpiresAt null.Time `boil:"expires_at" json:"expires_at,omitempty" toml:"expires_at" yaml:"expires_at,omitempty"` + AdminExpiresAt null.Time `boil:"admin_expires_at" json:"admin_expires_at,omitempty" toml:"admin_expires_at" yaml:"admin_expires_at,omitempty"` R *groupMembershipR `boil:"-" json:"-" toml:"-" yaml:"-"` L groupMembershipL `boil:"-" json:"-" toml:"-" yaml:"-"` } var GroupMembershipColumns = struct { - ID string - GroupID string - UserID string - IsAdmin string - CreatedAt string - UpdatedAt string - ExpiresAt string + ID string + GroupID string + UserID string + IsAdmin string + CreatedAt string + UpdatedAt string + ExpiresAt string + AdminExpiresAt string }{ - ID: "id", - GroupID: "group_id", - UserID: "user_id", - IsAdmin: "is_admin", - CreatedAt: "created_at", - UpdatedAt: "updated_at", - ExpiresAt: "expires_at", + ID: "id", + GroupID: "group_id", + UserID: "user_id", + IsAdmin: "is_admin", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + ExpiresAt: "expires_at", + AdminExpiresAt: "admin_expires_at", } var GroupMembershipTableColumns = struct { - ID string - GroupID string - UserID string - IsAdmin string - CreatedAt string - UpdatedAt string - ExpiresAt string + ID string + GroupID string + UserID string + IsAdmin string + CreatedAt string + UpdatedAt string + ExpiresAt string + AdminExpiresAt string }{ - ID: "group_memberships.id", - GroupID: "group_memberships.group_id", - UserID: "group_memberships.user_id", - IsAdmin: "group_memberships.is_admin", - CreatedAt: "group_memberships.created_at", - UpdatedAt: "group_memberships.updated_at", - ExpiresAt: "group_memberships.expires_at", + ID: "group_memberships.id", + GroupID: "group_memberships.group_id", + UserID: "group_memberships.user_id", + IsAdmin: "group_memberships.is_admin", + CreatedAt: "group_memberships.created_at", + UpdatedAt: "group_memberships.updated_at", + ExpiresAt: "group_memberships.expires_at", + AdminExpiresAt: "group_memberships.admin_expires_at", } // Generated where var GroupMembershipWhere = struct { - ID whereHelperstring - GroupID whereHelperstring - UserID whereHelperstring - IsAdmin whereHelperbool - CreatedAt whereHelpertime_Time - UpdatedAt whereHelpertime_Time - ExpiresAt whereHelpernull_Time + ID whereHelperstring + GroupID whereHelperstring + UserID whereHelperstring + IsAdmin whereHelperbool + CreatedAt whereHelpertime_Time + UpdatedAt whereHelpertime_Time + ExpiresAt whereHelpernull_Time + AdminExpiresAt whereHelpernull_Time }{ - ID: whereHelperstring{field: "\"group_memberships\".\"id\""}, - GroupID: whereHelperstring{field: "\"group_memberships\".\"group_id\""}, - UserID: whereHelperstring{field: "\"group_memberships\".\"user_id\""}, - IsAdmin: whereHelperbool{field: "\"group_memberships\".\"is_admin\""}, - CreatedAt: whereHelpertime_Time{field: "\"group_memberships\".\"created_at\""}, - UpdatedAt: whereHelpertime_Time{field: "\"group_memberships\".\"updated_at\""}, - ExpiresAt: whereHelpernull_Time{field: "\"group_memberships\".\"expires_at\""}, + ID: whereHelperstring{field: "\"group_memberships\".\"id\""}, + GroupID: whereHelperstring{field: "\"group_memberships\".\"group_id\""}, + UserID: whereHelperstring{field: "\"group_memberships\".\"user_id\""}, + IsAdmin: whereHelperbool{field: "\"group_memberships\".\"is_admin\""}, + CreatedAt: whereHelpertime_Time{field: "\"group_memberships\".\"created_at\""}, + UpdatedAt: whereHelpertime_Time{field: "\"group_memberships\".\"updated_at\""}, + ExpiresAt: whereHelpernull_Time{field: "\"group_memberships\".\"expires_at\""}, + AdminExpiresAt: whereHelpernull_Time{field: "\"group_memberships\".\"admin_expires_at\""}, } // GroupMembershipRels is where relationship names are stored. @@ -130,9 +137,9 @@ func (r *groupMembershipR) GetGroup() *Group { type groupMembershipL struct{} var ( - groupMembershipAllColumns = []string{"id", "group_id", "user_id", "is_admin", "created_at", "updated_at", "expires_at"} + groupMembershipAllColumns = []string{"id", "group_id", "user_id", "is_admin", "created_at", "updated_at", "expires_at", "admin_expires_at"} groupMembershipColumnsWithoutDefault = []string{"group_id", "user_id", "created_at", "updated_at"} - groupMembershipColumnsWithDefault = []string{"id", "is_admin", "expires_at"} + groupMembershipColumnsWithDefault = []string{"id", "is_admin", "expires_at", "admin_expires_at"} groupMembershipPrimaryKeyColumns = []string{"id"} groupMembershipGeneratedColumns = []string{} ) diff --git a/pkg/api/v1alpha1/auth.go b/pkg/api/v1alpha1/auth.go index f217e9f..90771a8 100644 --- a/pkg/api/v1alpha1/auth.go +++ b/pkg/api/v1alpha1/auth.go @@ -334,7 +334,14 @@ func (r *Router) mwGroupAuthRequired(authRole mwAuthRole) gin.HandlerFunc { if id == groupID { isGroupMember = true - isGroupAdmin = m.IsAdmin + + if m.AdminExpiresAt.Valid { + if time.Now().Before(m.AdminExpiresAt.Time) { + isGroupAdmin = m.IsAdmin + } + } else { + isGroupAdmin = m.IsAdmin + } break } diff --git a/pkg/api/v1alpha1/authenticated_user.go b/pkg/api/v1alpha1/authenticated_user.go index e948e34..64b559b 100644 --- a/pkg/api/v1alpha1/authenticated_user.go +++ b/pkg/api/v1alpha1/authenticated_user.go @@ -279,6 +279,7 @@ func (r *Router) getAuthenticatedUserGroupApprovals(c *gin.Context) { CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, IsAdmin: m.IsAdmin, + Kind: m.Kind, }, isGroupAdmin}) } } @@ -346,6 +347,7 @@ func (r *Router) getAuthenticatedUserGroupRequests(c *gin.Context) { UpdatedAt: m.UpdatedAt, IsAdmin: m.IsAdmin, Note: m.Note, + Kind: m.Kind, } memberRequests[i] = AuthenticatedUserGroupMemberRequest{ diff --git a/pkg/api/v1alpha1/errors.go b/pkg/api/v1alpha1/errors.go index 8e2b3d0..acd756e 100644 --- a/pkg/api/v1alpha1/errors.go +++ b/pkg/api/v1alpha1/errors.go @@ -11,6 +11,8 @@ var ( ErrInvalidChar = errors.New("invalid characters in group name string") // ErrEmptyInput is returned when user input is empty ErrEmptyInput = errors.New("name or description cannot be empty") + // ErrUnknownRequestKind is returned a request kind is unknown + ErrUnknownRequestKind = errors.New("request kind is unrecognized") ) func sendError(c *gin.Context, code int, msg string) { diff --git a/pkg/api/v1alpha1/group_membership.go b/pkg/api/v1alpha1/group_membership.go index 0fae547..813f81d 100644 --- a/pkg/api/v1alpha1/group_membership.go +++ b/pkg/api/v1alpha1/group_membership.go @@ -18,49 +18,63 @@ import ( events "github.com/metal-toolbox/governor-api/pkg/events/v1alpha1" ) +const ( + // NewMemberRequest represents requests from non-members to join a group + NewMemberRequest string = "new_member" + // AdminPromotionRequest represents requests from members to promote to admin access + AdminPromotionRequest string = "admin_promotion" +) + // GroupMember is a group member (user) type GroupMember struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - AvatarURL string `json:"avatar_url"` - Status string `json:"status"` - IsAdmin bool `json:"is_admin"` - ExpiresAt null.Time `json:"expires_at"` - Direct bool `json:"direct"` + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + Status string `json:"status"` + IsAdmin bool `json:"is_admin"` + ExpiresAt null.Time `json:"expires_at"` + AdminExpiresAt null.Time `json:"admin_expires_at"` + Direct bool `json:"direct"` } // GroupMembership is the relationship between user and groups type GroupMembership struct { - ID string `json:"id"` - GroupID string `json:"group_id"` - GroupSlug string `json:"group_slug"` - UserID string `json:"user_id"` - UserEmail string `json:"user_email"` - ExpiresAt null.Time `json:"expires_at"` + ID string `json:"id"` + GroupID string `json:"group_id"` + GroupSlug string `json:"group_slug"` + UserID string `json:"user_id"` + UserEmail string `json:"user_email"` + ExpiresAt null.Time `json:"expires_at"` + IsAdmin bool `json:"is_admin"` + AdminExpiresAt null.Time `json:"admin_expires_at"` } // GroupMemberRequest is a pending user request for group membership type GroupMemberRequest struct { - ID string `json:"id"` - GroupID string `json:"group_id"` - GroupName string `json:"group_name"` - GroupSlug string `json:"group_slug"` - UserID string `json:"user_id"` - UserName string `json:"user_name"` - UserEmail string `json:"user_email"` - UserAvatarURL string `json:"user_avatar_url"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - IsAdmin bool `json:"is_admin"` - Note string `json:"note"` - ExpiresAt null.Time `json:"expires_at"` + ID string `json:"id"` + GroupID string `json:"group_id"` + GroupName string `json:"group_name"` + GroupSlug string `json:"group_slug"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + UserAvatarURL string `json:"user_avatar_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IsAdmin bool `json:"is_admin"` + Note string `json:"note"` + ExpiresAt null.Time `json:"expires_at"` + AdminExpiresAt null.Time `json:"admin_expires_at"` + Kind string `json:"kind"` } type createGroupMemberReq struct { - IsAdmin bool `json:"is_admin"` - Note string `json:"note"` - ExpiresAt null.Time `json:"expires_at"` + IsAdmin bool `json:"is_admin"` + Note string `json:"note"` + ExpiresAt null.Time `json:"expires_at"` + AdminExpiresAt null.Time `json:"admin_expires_at"` + Kind string `json:"kind"` } // listGroupMembers returns a list of users in a group @@ -98,14 +112,15 @@ func (r *Router) listGroupMembers(c *gin.Context) { members := make([]GroupMember, len(enumeratedMembers)) for i, m := range enumeratedMembers { members[i] = GroupMember{ - ID: m.User.ID, - Name: m.User.Name, - Email: m.User.Email, - AvatarURL: m.User.AvatarURL.String, - Status: m.User.Status.String, - IsAdmin: m.IsAdmin, - ExpiresAt: m.ExpiresAt, - Direct: m.Direct, + ID: m.User.ID, + Name: m.User.Name, + Email: m.User.Email, + AvatarURL: m.User.AvatarURL.String, + Status: m.User.Status.String, + IsAdmin: m.IsAdmin, + ExpiresAt: m.ExpiresAt, + AdminExpiresAt: m.AdminExpiresAt, + Direct: m.Direct, } } @@ -148,8 +163,9 @@ func (r *Router) addGroupMember(c *gin.Context) { } req := struct { - IsAdmin bool `json:"is_admin"` - ExpiresAt null.Time `json:"expires_at"` + IsAdmin bool `json:"is_admin"` + ExpiresAt null.Time `json:"expires_at"` + AdminExpiresAt null.Time `json:"admin_expires_at"` }{} if err := c.BindJSON(&req); err != nil { @@ -172,10 +188,11 @@ func (r *Router) addGroupMember(c *gin.Context) { } groupMem := &models.GroupMembership{ - GroupID: group.ID, - UserID: user.ID, - IsAdmin: req.IsAdmin, - ExpiresAt: req.ExpiresAt, + GroupID: group.ID, + UserID: user.ID, + IsAdmin: req.IsAdmin, + ExpiresAt: req.ExpiresAt, + AdminExpiresAt: req.AdminExpiresAt, } tx, err := r.DB.BeginTx(c.Request.Context(), nil) @@ -320,7 +337,8 @@ func (r *Router) updateGroupMember(c *gin.Context) { } req := struct { - IsAdmin bool `json:"is_admin"` + IsAdmin bool `json:"is_admin"` + AdminExpiresAt null.Time `json:"admin_expires_at"` }{} if err := c.BindJSON(&req); err != nil { @@ -355,6 +373,8 @@ func (r *Router) updateGroupMember(c *gin.Context) { membership.IsAdmin = req.IsAdmin + membership.AdminExpiresAt = req.AdminExpiresAt + tx, err := r.DB.BeginTx(c.Request.Context(), nil) if err != nil { sendError(c, http.StatusBadRequest, "error starting update groups membership transaction: "+err.Error()) @@ -599,6 +619,19 @@ func (r *Router) createGroupRequest(c *gin.Context) { return } + // kind is not required but will be defaulted to new member if not set + if req.Kind == "" { + req.Kind = NewMemberRequest + } + + switch req.Kind { + case NewMemberRequest: + case AdminPromotionRequest: + default: + sendError(c, http.StatusBadRequest, "request kind is unrecognized: "+ErrUnknownRequestKind.Error()) + return + } + gid := c.Param("id") q := qm.Where("id = ?", gid) @@ -618,11 +651,25 @@ func (r *Router) createGroupRequest(c *gin.Context) { return } + foundExistingGroupMember := false + for _, m := range ctxUser.R.GroupMemberships { if m.GroupID == group.ID { + foundExistingGroupMember = true + } + } + + switch req.Kind { + case NewMemberRequest: + if foundExistingGroupMember { sendError(c, http.StatusBadRequest, "user already member of the group") return } + case AdminPromotionRequest: + if !foundExistingGroupMember { + sendError(c, http.StatusBadRequest, "user must be a member before making this request") + return + } } for _, r := range ctxUser.R.GroupMembershipRequests { @@ -633,11 +680,13 @@ func (r *Router) createGroupRequest(c *gin.Context) { } groupMembershipRequest := &models.GroupMembershipRequest{ - GroupID: group.ID, - UserID: ctxUser.ID, - IsAdmin: req.IsAdmin, - Note: req.Note, - ExpiresAt: req.ExpiresAt, + GroupID: group.ID, + UserID: ctxUser.ID, + IsAdmin: req.IsAdmin, + Note: req.Note, + ExpiresAt: req.ExpiresAt, + AdminExpiresAt: req.AdminExpiresAt, + Kind: req.Kind, } tx, err := r.DB.BeginTx(c.Request.Context(), nil) @@ -864,19 +913,21 @@ func (r *Router) getGroupRequests(c *gin.Context) { requests := make([]GroupMemberRequest, len(group.R.GroupMembershipRequests)) for i, m := range group.R.GroupMembershipRequests { requests[i] = GroupMemberRequest{ - ID: m.ID, - GroupID: m.GroupID, - GroupName: m.R.Group.Name, - GroupSlug: m.R.Group.Slug, - UserID: m.UserID, - UserName: m.R.User.Name, - UserEmail: m.R.User.Email, - UserAvatarURL: m.R.User.AvatarURL.String, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, - IsAdmin: m.IsAdmin, - Note: m.Note, - ExpiresAt: m.ExpiresAt, + ID: m.ID, + GroupID: m.GroupID, + GroupName: m.R.Group.Name, + GroupSlug: m.R.Group.Slug, + UserID: m.UserID, + UserName: m.R.User.Name, + UserEmail: m.R.User.Email, + UserAvatarURL: m.R.User.AvatarURL.String, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + IsAdmin: m.IsAdmin, + Note: m.Note, + ExpiresAt: m.ExpiresAt, + AdminExpiresAt: m.AdminExpiresAt, + Kind: m.Kind, } } @@ -947,8 +998,8 @@ func (r *Router) processGroupRequest(c *gin.Context) { switch req.Action { case "approve": - // approving a request will first check that the requesting user is not already a member - // of the group, then add them to the group, and finally delete the request + // approving a request will lookup the action to be performed, run checks, + // perform the appropriate approval action, and finally delete the request user, err := models.FindUser(c.Request.Context(), r.DB, request.UserID) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -961,37 +1012,48 @@ func (r *Router) processGroupRequest(c *gin.Context) { return } - exists, err := models.GroupMemberships( + existingMembership, err := models.GroupMemberships( qm.Where("group_id = ?", request.GroupID), qm.And("user_id = ?", request.UserID), - ).Exists(c.Request.Context(), r.DB) + ).One(c.Request.Context(), r.DB) if err != nil { - sendError(c, http.StatusInternalServerError, "error checking membership exists: "+err.Error()) - return + if !errors.Is(err, sql.ErrNoRows) { + sendError(c, http.StatusInternalServerError, "error checking membership exists: "+err.Error()) + return + } } - if exists { - // if the user is already a member of the group, we can just delete the request - if _, err := request.Delete(c.Request.Context(), r.DB); err != nil { - sendError(c, http.StatusBadRequest, "failed to delete group request: "+err.Error()) + // Type-specific checks before processing the approval + switch request.Kind { + case "new_member": + if existingMembership != nil { + // if the user is already a member of the group, we can just delete the request + if _, err := request.Delete(c.Request.Context(), r.DB); err != nil { + sendError(c, http.StatusBadRequest, "failed to delete group request: "+err.Error()) + return + } + + sendError(c, http.StatusConflict, "user already in group") + return } + case "admin_promotion": + if existingMembership.IsAdmin { + // if the user is already an admin, we can just delete the request + if _, err := request.Delete(c.Request.Context(), r.DB); err != nil { + sendError(c, http.StatusBadRequest, "failed to delete group request: "+err.Error()) + return + } - sendError(c, http.StatusConflict, "user already in group") + sendError(c, http.StatusConflict, "user already an admin") - return - } - - groupMem := &models.GroupMembership{ - GroupID: request.GroupID, - UserID: request.UserID, - IsAdmin: request.IsAdmin, - ExpiresAt: request.ExpiresAt, + return + } } tx, err := r.DB.BeginTx(c.Request.Context(), nil) if err != nil { - sendError(c, http.StatusBadRequest, "error starting group membership approval transaction: "+err.Error()) + sendError(c, http.StatusBadRequest, "error starting group request approval transaction: "+err.Error()) return } @@ -1008,20 +1070,47 @@ func (r *Router) processGroupRequest(c *gin.Context) { return } - if err := groupMem.Insert(c.Request.Context(), tx, boil.Infer()); err != nil { - msg := "error approving group membership request , rolling back: " + err.Error() + groupMem := &models.GroupMembership{ + GroupID: request.GroupID, + UserID: request.UserID, + IsAdmin: request.IsAdmin, + ExpiresAt: request.ExpiresAt, + AdminExpiresAt: request.AdminExpiresAt, + } - if err := tx.Rollback(); err != nil { - msg += "error rolling back transaction: " + err.Error() + // Process the approval + switch request.Kind { + case "new_member": + if err := groupMem.Insert(c.Request.Context(), tx, boil.Infer()); err != nil { + msg := "error approving group membership request , rolling back: " + err.Error() + + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return } + case "admin_promotion": + existingMembership.IsAdmin = true + existingMembership.AdminExpiresAt = request.AdminExpiresAt - sendError(c, http.StatusBadRequest, msg) + if _, err := existingMembership.Update(c.Request.Context(), tx, boil.Infer()); err != nil { + msg := "error approving admin promotion request , rolling back: " + err.Error() - return + if err := tx.Rollback(); err != nil { + msg += "error rolling back transaction: " + err.Error() + } + + sendError(c, http.StatusBadRequest, msg) + + return + } } if _, err := request.Delete(c.Request.Context(), tx); err != nil { - msg := "error deleting group membership request on approval, rolling back: " + err.Error() + msg := "error deleting group request on approval, rolling back: " + err.Error() if err := tx.Rollback(); err != nil { msg += "error rolling back transaction: " + err.Error() @@ -1032,9 +1121,9 @@ func (r *Router) processGroupRequest(c *gin.Context) { return } - event, err := dbtools.AuditGroupMembershipApproved(c.Request.Context(), tx, getCtxAuditID(c), ctxUser, groupMem) + event, err := dbtools.AuditGroupMembershipApproved(c.Request.Context(), tx, getCtxAuditID(c), ctxUser, groupMem, request.Kind) if err != nil { - msg := "error approving group membership request (audit): " + err.Error() + msg := "error approving group request (audit): " + err.Error() if err := tx.Rollback(); err != nil { msg += "error rolling back transaction: " + err.Error() @@ -1046,7 +1135,7 @@ func (r *Router) processGroupRequest(c *gin.Context) { } if err := updateContextWithAuditEventData(c, event); err != nil { - msg := "error approving group membership request (audit): " + err.Error() + msg := "error approving group request (audit): " + err.Error() if err := tx.Rollback(); err != nil { msg += "error rolling back transaction: " + err.Error() @@ -1071,7 +1160,7 @@ func (r *Router) processGroupRequest(c *gin.Context) { } if err := tx.Commit(); err != nil { - msg := "error committing group membership approval, rolling back: " + err.Error() + msg := "error committing group request approval, rolling back: " + err.Error() if err := tx.Rollback(); err != nil { msg += "error rolling back transaction: " + err.Error() @@ -1096,7 +1185,7 @@ func (r *Router) processGroupRequest(c *gin.Context) { UserID: groupMem.UserID, ActorID: getCtxActorID(c), }); err != nil { - sendError(c, http.StatusBadRequest, "failed to publish member request approve event, downstream changes may be delayed "+err.Error()) + sendError(c, http.StatusBadRequest, "failed to publish request approve event, downstream changes may be delayed "+err.Error()) return } @@ -1123,7 +1212,7 @@ func (r *Router) processGroupRequest(c *gin.Context) { case "deny": tx, err := r.DB.BeginTx(c.Request.Context(), nil) if err != nil { - sendError(c, http.StatusBadRequest, "error starting group membership denial transaction: "+err.Error()) + sendError(c, http.StatusBadRequest, "error starting group request denial transaction: "+err.Error()) return } @@ -1135,7 +1224,7 @@ func (r *Router) processGroupRequest(c *gin.Context) { event, err := dbtools.AuditGroupMembershipDenied(c.Request.Context(), tx, getCtxAuditID(c), ctxUser, request) if err != nil { - msg := "error denying group membership request (audit): " + err.Error() + msg := "error denying group request (audit): " + err.Error() if err := tx.Rollback(); err != nil { msg += "error rolling back transaction: " + err.Error() @@ -1147,7 +1236,7 @@ func (r *Router) processGroupRequest(c *gin.Context) { } if err := tx.Commit(); err != nil { - msg := "error committing group membership deny, rolling back: " + err.Error() + msg := "error committing group request deny, rolling back: " + err.Error() if err := tx.Rollback(); err != nil { msg += "error rolling back transaction: " + err.Error() @@ -1159,7 +1248,7 @@ func (r *Router) processGroupRequest(c *gin.Context) { } if err := updateContextWithAuditEventData(c, event); err != nil { - msg := "error denying group membership request (audit): " + err.Error() + msg := "error denying group request (audit): " + err.Error() if err := tx.Rollback(); err != nil { msg += "error rolling back transaction: " + err.Error() @@ -1178,7 +1267,7 @@ func (r *Router) processGroupRequest(c *gin.Context) { UserID: request.UserID, ActorID: getCtxActorID(c), }); err != nil { - sendError(c, http.StatusBadRequest, "failed to publish member request deny event, downstream changes may be delayed "+err.Error()) + sendError(c, http.StatusBadRequest, "failed to publish request deny event, downstream changes may be delayed "+err.Error()) return } @@ -1216,12 +1305,14 @@ func (r *Router) getGroupMembershipsAll(c *gin.Context) { response = make([]GroupMembership, len(groupMemberships)) for i, m := range groupMemberships { response[i] = GroupMembership{ - ID: m.ID, - GroupID: m.GroupID, - GroupSlug: m.R.Group.Slug, - UserID: m.UserID, - UserEmail: m.R.User.Email, - ExpiresAt: m.ExpiresAt, + ID: m.ID, + GroupID: m.GroupID, + GroupSlug: m.R.Group.Slug, + UserID: m.UserID, + UserEmail: m.R.User.Email, + ExpiresAt: m.ExpiresAt, + IsAdmin: m.IsAdmin, + AdminExpiresAt: m.AdminExpiresAt, } } } else { @@ -1236,12 +1327,14 @@ func (r *Router) getGroupMembershipsAll(c *gin.Context) { response = make([]GroupMembership, len(enumeratedMemberships)) for i, m := range enumeratedMemberships { response[i] = GroupMembership{ - ID: "", - GroupID: m.GroupID, - GroupSlug: m.Group.Slug, - UserID: m.UserID, - UserEmail: m.User.Email, - ExpiresAt: m.ExpiresAt, + ID: "", + GroupID: m.GroupID, + GroupSlug: m.Group.Slug, + UserID: m.UserID, + UserEmail: m.User.Email, + ExpiresAt: m.ExpiresAt, + IsAdmin: m.IsAdmin, + AdminExpiresAt: m.AdminExpiresAt, } } } @@ -1272,19 +1365,21 @@ func (r *Router) getGroupRequestsAll(c *gin.Context) { response := make([]GroupMemberRequest, len(groupMembershipRequests)) for i, m := range groupMembershipRequests { response[i] = GroupMemberRequest{ - ID: m.ID, - GroupID: m.GroupID, - GroupName: m.R.Group.Name, - GroupSlug: m.R.Group.Slug, - UserID: m.UserID, - UserName: m.R.User.Name, - UserEmail: m.R.User.Email, - UserAvatarURL: m.R.User.AvatarURL.String, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, - IsAdmin: m.IsAdmin, - Note: m.Note, - ExpiresAt: m.ExpiresAt, + ID: m.ID, + GroupID: m.GroupID, + GroupName: m.R.Group.Name, + GroupSlug: m.R.Group.Slug, + UserID: m.UserID, + UserName: m.R.User.Name, + UserEmail: m.R.User.Email, + UserAvatarURL: m.R.User.AvatarURL.String, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + IsAdmin: m.IsAdmin, + Note: m.Note, + ExpiresAt: m.ExpiresAt, + AdminExpiresAt: m.AdminExpiresAt, + Kind: m.Kind, } } diff --git a/pkg/client/groups.go b/pkg/client/groups.go index 91f0b24..d10f65b 100644 --- a/pkg/client/groups.go +++ b/pkg/client/groups.go @@ -279,6 +279,46 @@ func (c *Client) RemoveGroupMember(ctx context.Context, groupID, userID string) return nil } +// UpdateGroupMember updates a group membership in governor +func (c *Client) UpdateGroupMember(ctx context.Context, groupID, userID string, admin bool) error { + if groupID == "" { + return ErrMissingGroupID + } + + if userID == "" { + return ErrMissingUserID + } + + req, err := c.newGovernorRequest(ctx, http.MethodPatch, fmt.Sprintf("%s/api/%s/groups/%s/users/%s", c.url, governorAPIVersionAlpha, groupID, userID)) + if err != nil { + return err + } + + b, err := json.Marshal(struct { + IsAdmin bool `json:"is_admin"` + }{admin}) + if err != nil { + return err + } + + req.Body = io.NopCloser(bytes.NewBuffer(b)) + + resp, err := c.httpClient.Do(req.WithContext(ctx)) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && + resp.StatusCode != http.StatusAccepted && + resp.StatusCode != http.StatusNoContent { + return ErrRequestNonSuccess + } + + return nil +} + // AddGroupToOrganization links the group to the organization func (c *Client) AddGroupToOrganization(ctx context.Context, groupID, orgID string) error { if groupID == "" {