diff --git a/apps/console/src/__generated__/iam/MemberListFragment_RefetchQuery.graphql.ts b/apps/console/src/__generated__/iam/MemberListFragment_RefetchQuery.graphql.ts index af86f5512..e137bee8b 100644 --- a/apps/console/src/__generated__/iam/MemberListFragment_RefetchQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/MemberListFragment_RefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<7759f335d6dd4b9ca6afbb601aa7794c>> * @lightSyntaxTransform * @nogrep */ @@ -236,6 +236,13 @@ return { "name": "source", "storageKey": null }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + }, { "alias": null, "args": null, @@ -396,12 +403,12 @@ return { ] }, "params": { - "cacheID": "bdedd349fa3a3dec43d7cb6171d1cc6d", + "cacheID": "bb44371bed2eb3032b52ea5bbb3109c1", "id": null, "metadata": {}, "name": "MemberListFragment_RefetchQuery", "operationKind": "query", - "text": "query MemberListFragment_RefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: MembershipOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MemberListFragment_16fISc\n id\n }\n}\n\nfragment MemberListFragment_16fISc on Organization {\n members(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n source\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership:delete\")\n}\n" + "text": "query MemberListFragment_RefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: MembershipOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MemberListFragment_16fISc\n id\n }\n}\n\nfragment MemberListFragment_16fISc on Organization {\n members(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n source\n state\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership:delete\")\n}\n" } }; })(); diff --git a/apps/console/src/__generated__/iam/MemberListItemFragment.graphql.ts b/apps/console/src/__generated__/iam/MemberListItemFragment.graphql.ts index 56fab7e84..8842022f6 100644 --- a/apps/console/src/__generated__/iam/MemberListItemFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/MemberListItemFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<13e173a42e2ea29b7960539683cea087>> + * @generated SignedSource<<81a5acfba89528014adf0731f16b9a90>> * @lightSyntaxTransform * @nogrep */ @@ -11,6 +11,7 @@ import { ReaderFragment } from 'relay-runtime'; export type MembershipRole = "ADMIN" | "AUDITOR" | "EMPLOYEE" | "OWNER" | "VIEWER"; export type MembershipSource = "MANUAL" | "SAML" | "SCIM"; +export type MembershipState = "ACTIVE" | "INACTIVE"; import { FragmentRefs } from "relay-runtime"; export type MemberListItemFragment$data = { readonly canDelete: boolean; @@ -25,6 +26,7 @@ export type MemberListItemFragment$data = { }; readonly role: MembershipRole; readonly source: MembershipSource; + readonly state: MembershipState; readonly " $fragmentType": "MemberListItemFragment"; }; export type MemberListItemFragment$key = { @@ -59,6 +61,13 @@ const node: ReaderFragment = { "name": "source", "storageKey": null }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + }, { "kind": "RequiredField", "field": { @@ -141,6 +150,6 @@ const node: ReaderFragment = { "abstractKey": null }; -(node as any).hash = "573655b969ddacac10dc8beb4585a4a1"; +(node as any).hash = "ffa3431077faeeb5e5779975d68c82bb"; export default node; diff --git a/apps/console/src/__generated__/iam/MembersPageQuery.graphql.ts b/apps/console/src/__generated__/iam/MembersPageQuery.graphql.ts index 462cb23ff..18592f833 100644 --- a/apps/console/src/__generated__/iam/MembersPageQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/MembersPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -345,6 +345,13 @@ return { "name": "source", "storageKey": null }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + }, { "alias": null, "args": null, @@ -517,12 +524,12 @@ return { ] }, "params": { - "cacheID": "b7d449bd737c6165219640affcc0260a", + "cacheID": "9cc9ff1663db9c476c1cd67f097a4ab9", "id": null, "metadata": {}, "name": "MembersPageQuery", "operationKind": "query", - "text": "query MembersPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...InviteUserDialog_currentRoleFragment\n ...MemberListFragment_1jRT0c\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n }\n ...InvitationListFragment_1jRT0c\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n }\n canInviteUser: permission(action: \"iam:invitation:create\")\n }\n id\n }\n}\n\nfragment InvitationListFragment_1jRT0c on Organization {\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n ...InvitationListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment InvitationListItemFragment on Invitation {\n id\n fullName\n email\n role\n status\n createdAt\n expiresAt\n acceptedAt\n canDelete: permission(action: \"iam:invitation:delete\")\n}\n\nfragment InviteUserDialog_currentRoleFragment on Organization {\n viewerMembership {\n role\n id\n }\n}\n\nfragment MemberListFragment_1jRT0c on Organization {\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n source\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership:delete\")\n}\n" + "text": "query MembersPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n ...InviteUserDialog_currentRoleFragment\n ...MemberListFragment_1jRT0c\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n }\n ...InvitationListFragment_1jRT0c\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n }\n canInviteUser: permission(action: \"iam:invitation:create\")\n }\n id\n }\n}\n\nfragment InvitationListFragment_1jRT0c on Organization {\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n ...InvitationListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment InvitationListItemFragment on Invitation {\n id\n fullName\n email\n role\n status\n createdAt\n expiresAt\n acceptedAt\n canDelete: permission(action: \"iam:invitation:delete\")\n}\n\nfragment InviteUserDialog_currentRoleFragment on Organization {\n viewerMembership {\n role\n id\n }\n}\n\nfragment MemberListFragment_1jRT0c on Organization {\n members(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n ...MemberListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MemberListItemFragment on Membership {\n id\n role\n source\n state\n profile {\n fullName\n id\n }\n identity {\n email\n id\n }\n createdAt\n canUpdate: permission(action: \"iam:membership:update\")\n canDelete: permission(action: \"iam:membership:delete\")\n}\n" } }; })(); diff --git a/apps/console/src/pages/iam/organizations/settings/_components/MemberListItem.tsx b/apps/console/src/pages/iam/organizations/settings/_components/MemberListItem.tsx index 70eadded8..e14f2568d 100644 --- a/apps/console/src/pages/iam/organizations/settings/_components/MemberListItem.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/MemberListItem.tsx @@ -25,6 +25,7 @@ const fragment = graphql` id role source + state profile @required(action: THROW) { fullName } @@ -63,6 +64,8 @@ export function MemberListItem(props: { const membership = useFragment(fragment, fKey); const { role } = use(CurrentUser); + const isInactive = membership.state === "INACTIVE"; + // Only OWNER can edit OWNER members const canEditThisRole = membership.role === "OWNER" ? role === "OWNER" : true; @@ -98,9 +101,17 @@ export function MemberListItem(props: { return ( <> - + -
{membership.profile.fullName}
+
+ {membership.profile.fullName} + {isInactive && {__("Inactive")}} +
@@ -113,34 +124,36 @@ export function MemberListItem(props: { {new Date(membership.createdAt).toLocaleDateString()} -
e.stopPropagation()} - > - {membership.canUpdate && canEditThisRole && ( -
+ )} + {isRemoving ? ( + + ) : ( + membership.canDelete && + canEditThisRole && ( +
+ )} diff --git a/pkg/coredata/membership.go b/pkg/coredata/membership.go index 599b83506..4d1d6a79d 100644 --- a/pkg/coredata/membership.go +++ b/pkg/coredata/membership.go @@ -36,6 +36,7 @@ type ( OrganizationID gid.GID `db:"organization_id"` Role MembershipRole `db:"role"` Source MembershipSource `db:"source"` + State MembershipState `db:"state"` FullName string `db:"full_name"` EmailAddress mail.Addr `db:"email_address"` CreatedAt time.Time `db:"created_at"` @@ -69,6 +70,7 @@ WITH mbr AS ( organization_id, role, source, + state, created_at, updated_at FROM @@ -83,6 +85,7 @@ SELECT mbr.organization_id, mbr.role, mbr.source, + mbr.state, COALESCE(mp.full_name, i.full_name, '') as full_name, i.email_address, mbr.created_at, @@ -126,6 +129,7 @@ INSERT INTO organization_id, role, source, + state, created_at, updated_at ) @@ -136,6 +140,7 @@ VALUES ( @organization_id, @role, @source, + @state, @created_at, @updated_at ); @@ -148,6 +153,7 @@ VALUES ( "organization_id": m.OrganizationID, "role": m.Role, "source": m.Source, + "state": m.State, "created_at": m.CreatedAt, "updated_at": m.UpdatedAt, } @@ -183,6 +189,7 @@ WITH mbr AS ( organization_id, role, source, + state, created_at, updated_at FROM @@ -197,6 +204,7 @@ SELECT mbr.organization_id, mbr.role, mbr.source, + mbr.state, COALESCE(mp.full_name, i.full_name, '') as full_name, i.email_address, mbr.created_at, @@ -334,6 +342,7 @@ WITH mbr AS ( am.organization_id, am.role, am.source, + am.state, am.created_at, am.updated_at FROM @@ -349,6 +358,7 @@ SELECT mbr.organization_id, mbr.role, mbr.source, + mbr.state, COALESCE(mp.full_name, i.full_name, '') as full_name, i.email_address, mbr.created_at, @@ -402,6 +412,7 @@ WITH mbr AS ( am.organization_id, am.role, am.source, + am.state, am.created_at, am.updated_at FROM @@ -419,6 +430,7 @@ SELECT mbr.organization_id, mbr.role, mbr.source, + mbr.state, COALESCE(mp.full_name, i.full_name, '') as full_name, i.email_address, mbr.created_at, @@ -464,6 +476,7 @@ UPDATE SET role = @role, source = @source, + state = @state, updated_at = @updated_at WHERE id = @id @@ -476,6 +489,7 @@ WHERE "id": m.ID, "role": m.Role, "source": m.Source, + "state": m.State, "updated_at": m.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) @@ -535,12 +549,14 @@ WITH mbr AS ( organization_id, role, source, + state, created_at, updated_at FROM iam_memberships WHERE identity_id = @identity_id + AND state = 'ACTIVE' AND %s ORDER BY created_at DESC @@ -551,6 +567,7 @@ SELECT mbr.organization_id, mbr.role, mbr.source, + mbr.state, COALESCE(mp.full_name, i.full_name, '') as full_name, i.email_address, mbr.created_at, @@ -603,6 +620,7 @@ WITH membership_with_profile AS ( m.organization_id, m.role, m.source, + m.state, COALESCE(mp.full_name, i.full_name, '') AS full_name, i.email_address, m.created_at, @@ -624,6 +642,7 @@ SELECT organization_id, role, source, + state, full_name, email_address, created_at, @@ -702,6 +721,7 @@ FROM iam_memberships WHERE identity_id = @identity_id + AND state = 'ACTIVE' ` args := pgx.StrictNamedArgs{ "identity_id": identityID, @@ -728,6 +748,7 @@ SELECT organization_id, role, source, + state, '' as full_name, NULL as email_address, created_at, diff --git a/pkg/coredata/membership_state.go b/pkg/coredata/membership_state.go new file mode 100644 index 000000000..657b1c5f0 --- /dev/null +++ b/pkg/coredata/membership_state.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "database/sql/driver" + "fmt" +) + +type MembershipState string + +const ( + MembershipStateActive MembershipState = "ACTIVE" + MembershipStateInactive MembershipState = "INACTIVE" +) + +func (s MembershipState) String() string { + return string(s) +} + +func (s *MembershipState) Scan(value any) error { + var str string + switch v := value.(type) { + case string: + str = v + case []byte: + str = string(v) + default: + return fmt.Errorf("unsupported type for MembershipState: %T", value) + } + + switch str { + case "ACTIVE": + *s = MembershipStateActive + case "INACTIVE": + *s = MembershipStateInactive + default: + return fmt.Errorf("invalid MembershipState value: %q", str) + } + return nil +} + +func (s MembershipState) Value() (driver.Value, error) { + return s.String(), nil +} diff --git a/pkg/coredata/migrations/20260105T134456Z.sql b/pkg/coredata/migrations/20260105T134456Z.sql new file mode 100644 index 000000000..dea60ac66 --- /dev/null +++ b/pkg/coredata/migrations/20260105T134456Z.sql @@ -0,0 +1,5 @@ +CREATE TYPE membership_state AS ENUM ('ACTIVE', 'INACTIVE'); + +ALTER TABLE iam_memberships ADD COLUMN state membership_state NOT NULL DEFAULT 'ACTIVE'; +CREATE INDEX idx_iam_memberships_state ON iam_memberships(state); + diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index e90acffa0..836e74190 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -243,32 +243,52 @@ func (s *AccountService) AcceptInvitation( tenantID := invitation.OrganizationID.TenantID() scope := coredata.NewScope(invitation.OrganizationID.TenantID()) - membership = &coredata.Membership{ - ID: gid.New(tenantID, coredata.MembershipEntityType), - IdentityID: identityID, - OrganizationID: invitation.OrganizationID, - Role: invitation.Role, - Source: coredata.MembershipSourceManual, - CreatedAt: now, - UpdatedAt: now, + existingMembership := &coredata.Membership{} + err = existingMembership.LoadByIdentityAndOrg(ctx, tx, scope, identityID, invitation.OrganizationID) + if err != nil && err != coredata.ErrResourceNotFound { + return fmt.Errorf("cannot load existing membership: %w", err) } - err = membership.Insert(ctx, tx, scope) - if err != nil { - return fmt.Errorf("cannot create membership: %w", err) - } + if existingMembership.ID != gid.Nil && existingMembership.State == coredata.MembershipStateInactive { + existingMembership.State = coredata.MembershipStateActive + existingMembership.Role = invitation.Role + existingMembership.UpdatedAt = now - profile := &coredata.MembershipProfile{ - ID: gid.New(tenantID, coredata.MembershipProfileEntityType), - MembershipID: membership.ID, - FullName: identity.FullName, - CreatedAt: now, - UpdatedAt: now, - } + err = existingMembership.Update(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot reactivate membership: %w", err) + } - err = profile.Insert(ctx, tx) - if err != nil { - return fmt.Errorf("cannot insert profile: %w", err) + membership = existingMembership + } else { + membership = &coredata.Membership{ + ID: gid.New(tenantID, coredata.MembershipEntityType), + IdentityID: identityID, + OrganizationID: invitation.OrganizationID, + Role: invitation.Role, + Source: coredata.MembershipSourceManual, + State: coredata.MembershipStateActive, + CreatedAt: now, + UpdatedAt: now, + } + + err = membership.Insert(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot create membership: %w", err) + } + + profile := &coredata.MembershipProfile{ + ID: gid.New(tenantID, coredata.MembershipProfileEntityType), + MembershipID: membership.ID, + FullName: identity.FullName, + CreatedAt: now, + UpdatedAt: now, + } + + err = profile.Insert(ctx, tx) + if err != nil { + return fmt.Errorf("cannot insert profile: %w", err) + } } invitation.AcceptedAt = &now diff --git a/pkg/iam/authorizer.go b/pkg/iam/authorizer.go index f27a66a71..2ebad204e 100644 --- a/pkg/iam/authorizer.go +++ b/pkg/iam/authorizer.go @@ -190,7 +190,7 @@ func (a *Authorizer) buildPoliciesForRole(role string) []*policy.Policy { func findRoleForOrg(memberships coredata.Memberships, orgID string) string { for _, m := range memberships { - if m.OrganizationID.String() == orgID { + if m.OrganizationID.String() == orgID && m.State == coredata.MembershipStateActive { return string(m.Role) } } diff --git a/pkg/iam/errors.go b/pkg/iam/errors.go index c30d1c71a..3cbd2bdfa 100644 --- a/pkg/iam/errors.go +++ b/pkg/iam/errors.go @@ -123,6 +123,18 @@ func (e ErrMembershipNotFound) Error() string { return fmt.Sprintf("membership %q not found", e.MembershipID) } +type ErrMembershipInactive struct { + MembershipID gid.GID +} + +func NewMembershipInactiveError(membershipID gid.GID) error { + return &ErrMembershipInactive{MembershipID: membershipID} +} + +func (e ErrMembershipInactive) Error() string { + return fmt.Sprintf("membership %q is inactive", e.MembershipID) +} + type ErrOrganizationNotFound struct{ OrganizationID gid.GID } func NewOrganizationNotFoundError(organizationID gid.GID) error { diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index a61e97a98..3a28dc4e7 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -403,7 +403,7 @@ func (s *OrganizationService) InviteMember( return fmt.Errorf("cannot load membership: %w", err) } - if membership.ID != gid.Nil { + if membership.ID != gid.Nil && membership.State == coredata.MembershipStateActive { return NewMembershipAlreadyExistsError(identity.ID, organizationID) } } @@ -493,6 +493,7 @@ func (s *OrganizationService) CreateOrganization( OrganizationID: organizationID, Role: coredata.MembershipRoleOwner, Source: coredata.MembershipSourceManual, + State: coredata.MembershipStateActive, CreatedAt: now, UpdatedAt: now, } diff --git a/pkg/iam/saml/errors.go b/pkg/iam/saml/errors.go index a135ddb36..ee399ffc5 100644 --- a/pkg/iam/saml/errors.go +++ b/pkg/iam/saml/errors.go @@ -88,3 +88,13 @@ func NewSAMLAutoSignupDisabledError(configID gid.GID) error { func (e ErrSAMLAutoSignupDisabled) Error() string { return fmt.Sprintf("SAML auto-signup is disabled for configuration %q", e.ConfigID) } + +type ErrMembershipInactive struct{ MembershipID gid.GID } + +func NewMembershipInactiveError(membershipID gid.GID) error { + return &ErrMembershipInactive{MembershipID: membershipID} +} + +func (e ErrMembershipInactive) Error() string { + return fmt.Sprintf("membership %q is inactive", e.MembershipID) +} diff --git a/pkg/iam/saml/service.go b/pkg/iam/saml/service.go index 34b25a020..938483427 100644 --- a/pkg/iam/saml/service.go +++ b/pkg/iam/saml/service.go @@ -291,6 +291,10 @@ func (s *Service) HandleAssertion( return fmt.Errorf("cannot load membership: %w", err) } + if membership.ID != gid.Nil && membership.State == coredata.MembershipStateInactive { + return NewMembershipInactiveError(membership.ID) + } + isMember := membership.ID != gid.Nil if !isMember { membership = &coredata.Membership{ @@ -299,6 +303,7 @@ func (s *Service) HandleAssertion( OrganizationID: config.OrganizationID, Role: coredata.MembershipRoleViewer, Source: coredata.MembershipSourceSAML, + State: coredata.MembershipStateActive, CreatedAt: now, UpdatedAt: now, } diff --git a/pkg/iam/scim/service.go b/pkg/iam/scim/service.go index 080965cf9..c24415e5d 100644 --- a/pkg/iam/scim/service.go +++ b/pkg/iam/scim/service.go @@ -71,16 +71,19 @@ func (s *Service) ValidateToken(ctx context.Context, token string) (*coredata.SC hashedToken := HashToken(token) config := &coredata.SCIMConfiguration{} - err := s.pg.WithConn(ctx, func(conn pg.Conn) error { - err := config.LoadByHashedToken(ctx, conn, hashedToken) - if err != nil { - if err == coredata.ErrResourceNotFound { - return NewSCIMInvalidTokenError() + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + err := config.LoadByHashedToken(ctx, conn, hashedToken) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewSCIMInvalidTokenError() + } + return fmt.Errorf("cannot load SCIM configuration: %w", err) } - return fmt.Errorf("cannot load SCIM configuration: %w", err) - } - return nil - }) + return nil + }, + ) if err != nil { return nil, err @@ -89,7 +92,6 @@ func (s *Service) ValidateToken(ctx context.Context, token string) (*coredata.SC return config, nil } -// CreateUser creates a new user via SCIM provisioning func (s *Service) CreateUser( ctx context.Context, config *coredata.SCIMConfiguration, @@ -147,6 +149,7 @@ func (s *Service) CreateUser( OrganizationID: config.OrganizationID, Role: coredata.MembershipRoleViewer, Source: coredata.MembershipSourceSCIM, + State: coredata.MembershipStateActive, CreatedAt: now, UpdatedAt: now, } @@ -172,8 +175,9 @@ func (s *Service) CreateUser( } else if err != nil { return fmt.Errorf("cannot load membership: %w", err) } else { - // Update existing membership source to SCIM + // Update existing membership - reactivate if inactive, update source to SCIM membership.Source = coredata.MembershipSourceSCIM + membership.State = coredata.MembershipStateActive membership.UpdatedAt = now err = membership.Update(ctx, tx, scope) @@ -189,10 +193,9 @@ func (s *Service) CreateUser( return scim.Resource{}, err } - return membershipToResource(membership, true), nil + return membershipToResource(membership), nil } -// GetUser gets a user by membership ID func (s *Service) GetUser( ctx context.Context, config *coredata.SCIMConfiguration, @@ -214,7 +217,6 @@ func (s *Service) GetUser( return fmt.Errorf("cannot load membership: %w", err) } - // Verify membership belongs to this organization if membership.OrganizationID != config.OrganizationID { return scimerrors.ScimErrorResourceNotFound(membershipID.String()) } @@ -227,7 +229,7 @@ func (s *Service) GetUser( return scim.Resource{}, err } - return membershipToResource(membership, true), nil + return membershipToResource(membership), nil } // ListUsers lists all users in an organization, with optional filter support @@ -248,26 +250,29 @@ func (s *Service) ListUsers( var memberships coredata.Memberships var totalCount int - err = s.pg.WithConn(ctx, func(conn pg.Conn) error { - var err error - totalCount, err = memberships.CountByOrganizationID(ctx, conn, scope, config.OrganizationID, filter) - if err != nil { - return fmt.Errorf("cannot count memberships: %w", err) - } + err = s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + var err error + totalCount, err = memberships.CountByOrganizationID(ctx, conn, scope, config.OrganizationID, filter) + if err != nil { + return fmt.Errorf("cannot count memberships: %w", err) + } - orderBy := page.OrderBy[coredata.MembershipOrderField]{ - Field: coredata.MembershipOrderFieldCreatedAt, - Direction: page.OrderDirectionDesc, - } - cursor := page.NewCursor(count, nil, page.Head, orderBy) + orderBy := page.OrderBy[coredata.MembershipOrderField]{ + Field: coredata.MembershipOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + cursor := page.NewCursor(count, nil, page.Head, orderBy) - err = memberships.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor, filter) - if err != nil { - return fmt.Errorf("cannot load memberships: %w", err) - } + err = memberships.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor, filter) + if err != nil { + return fmt.Errorf("cannot load memberships: %w", err) + } - return nil - }) + return nil + }, + ) if err != nil { return nil, 0, err @@ -275,13 +280,12 @@ func (s *Service) ListUsers( resources := make([]scim.Resource, 0, len(memberships)) for _, m := range memberships { - resources = append(resources, membershipToResource(m, true)) + resources = append(resources, membershipToResource(m)) } return resources, totalCount, nil } -// ReplaceUser replaces a user via SCIM PUT func (s *Service) ReplaceUser( ctx context.Context, config *coredata.SCIMConfiguration, @@ -289,14 +293,13 @@ func (s *Service) ReplaceUser( attributes scim.ResourceAttributes, ) (scim.Resource, error) { fullName, active := ParseUserFromReplaceAttributes(attributes) - membership, deactivated, err := s.updateUser(ctx, config, membershipID, fullName, active) + membership, err := s.updateUser(ctx, config, membershipID, fullName, active) if err != nil { return scim.Resource{}, err } - return membershipToResource(membership, !deactivated), nil + return membershipToResource(membership), nil } -// PatchUser patches a user via SCIM PATCH func (s *Service) PatchUser( ctx context.Context, config *coredata.SCIMConfiguration, @@ -304,11 +307,11 @@ func (s *Service) PatchUser( operations []scim.PatchOperation, ) (scim.Resource, error) { fullName, active := ParseUserFromPatchOperations(operations) - membership, deactivated, err := s.updateUser(ctx, config, membershipID, fullName, active) + membership, err := s.updateUser(ctx, config, membershipID, fullName, active) if err != nil { return scim.Resource{}, err } - return membershipToResource(membership, !deactivated), nil + return membershipToResource(membership), nil } func (s *Service) updateUser( @@ -317,12 +320,11 @@ func (s *Service) updateUser( membershipID gid.GID, fullName string, active *bool, -) (*coredata.Membership, bool, error) { +) (*coredata.Membership, error) { scope := coredata.NewScopeFromObjectID(config.OrganizationID) now := time.Now() var membership *coredata.Membership - var deactivated bool err := s.pg.WithTx(ctx, func(tx pg.Conn) error { membership = &coredata.Membership{} @@ -334,35 +336,35 @@ func (s *Service) updateUser( return fmt.Errorf("cannot load membership: %w", err) } - // Verify membership belongs to this organization if membership.OrganizationID != config.OrganizationID { return scimerrors.ScimErrorResourceNotFound(membershipID.String()) } - // Handle deactivation - Okta sends PATCH with active=false to deprovision users - if active != nil && !*active { - err = membership.Delete(ctx, tx, scope, membershipID) - if err != nil { - return fmt.Errorf("cannot delete membership: %w", err) + needsUpdate := false + + if active != nil { + if *active && membership.State == coredata.MembershipStateInactive { + membership.State = coredata.MembershipStateActive + needsUpdate = true + } else if !*active && membership.State == coredata.MembershipStateActive { + membership.State = coredata.MembershipStateInactive + needsUpdate = true } - - deactivated = true - - return nil } - // Update membership source to SCIM if not already if membership.Source != coredata.MembershipSourceSCIM { membership.Source = coredata.MembershipSourceSCIM - membership.UpdatedAt = now + needsUpdate = true + } + if needsUpdate { + membership.UpdatedAt = now err = membership.Update(ctx, tx, scope) if err != nil { return fmt.Errorf("cannot update membership: %w", err) } } - // Update membership profile profile := &coredata.MembershipProfile{} err = profile.LoadByMembershipID(ctx, tx, scope, membershipID) if err == nil { @@ -381,13 +383,12 @@ func (s *Service) updateUser( }) if err != nil { - return nil, false, err + return nil, err } - return membership, deactivated, nil + return membership, nil } -// DeleteUser removes a user's membership from the organization func (s *Service) DeleteUser( ctx context.Context, config *coredata.SCIMConfiguration, @@ -395,31 +396,32 @@ func (s *Service) DeleteUser( ) error { scope := coredata.NewScopeFromObjectID(config.OrganizationID) - return s.pg.WithTx(ctx, func(tx pg.Conn) error { - membership := &coredata.Membership{} - err := membership.LoadByID(ctx, tx, scope, membershipID) - if err != nil { - if err == coredata.ErrResourceNotFound { + return s.pg.WithTx( + ctx, + func(tx pg.Conn) error { + membership := &coredata.Membership{} + err := membership.LoadByID(ctx, tx, scope, membershipID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return scimerrors.ScimErrorResourceNotFound(membershipID.String()) + } + return fmt.Errorf("cannot load membership: %w", err) + } + + if membership.OrganizationID != config.OrganizationID { return scimerrors.ScimErrorResourceNotFound(membershipID.String()) } - return fmt.Errorf("cannot load membership: %w", err) - } - // Verify membership belongs to this organization - if membership.OrganizationID != config.OrganizationID { - return scimerrors.ScimErrorResourceNotFound(membershipID.String()) - } + err = membership.Delete(ctx, tx, scope, membershipID) + if err != nil { + return fmt.Errorf("cannot delete membership: %w", err) + } - err = membership.Delete(ctx, tx, scope, membershipID) - if err != nil { - return fmt.Errorf("cannot delete membership: %w", err) - } - - return nil - }) + return nil + }, + ) } -// LogEvent logs a SCIM event func (s *Service) LogEvent( ctx context.Context, config *coredata.SCIMConfiguration, @@ -439,9 +441,16 @@ func (s *Service) LogEvent( event := s.createEvent(config, method, path, mID, ipAddress, statusCode, errorMessage) - err := s.pg.WithConn(ctx, func(conn pg.Conn) error { - return event.Insert(ctx, conn, scope) - }) + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + err := event.Insert(ctx, conn, scope) + if err != nil { + return fmt.Errorf("cannot insert SCIM event: %w", err) + } + return nil + }, + ) if err != nil { s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) @@ -476,22 +485,21 @@ func (s *Service) createEvent( return event } -// ParseUserFromAttributes extracts user data from SCIM create attributes func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string, fullName string) { userName, _ := attributes["userName"].(string) displayName, _ := attributes["displayName"].(string) var givenName, familyName string - if name, ok := attributes["name"].(map[string]interface{}); ok { + if name, ok := attributes["name"].(map[string]any); ok { givenName, _ = name["givenName"].(string) familyName, _ = name["familyName"].(string) } // Get email from emails array or use userName email = userName - if emails, ok := attributes["emails"].([]interface{}); ok && len(emails) > 0 { + if emails, ok := attributes["emails"].([]any); ok && len(emails) > 0 { for _, e := range emails { - if emailMap, ok := e.(map[string]interface{}); ok { + if emailMap, ok := e.(map[string]any); ok { if primary, _ := emailMap["primary"].(bool); primary { if value, ok := emailMap["value"].(string); ok { email = value @@ -502,7 +510,7 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string, } // If no primary email found, use the first one if email == userName { - if emailMap, ok := emails[0].(map[string]interface{}); ok { + if emailMap, ok := emails[0].(map[string]any); ok { if value, ok := emailMap["value"].(string); ok { email = value } @@ -522,12 +530,11 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) (email string, return email, fullName } -// ParseUserFromReplaceAttributes extracts user data from SCIM replace (PUT) attributes func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) (fullName string, active *bool) { displayName, _ := attributes["displayName"].(string) var givenName, familyName string - if name, ok := attributes["name"].(map[string]interface{}); ok { + if name, ok := attributes["name"].(map[string]any); ok { givenName, _ = name["givenName"].(string) familyName, _ = name["familyName"].(string) } @@ -545,7 +552,6 @@ func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) (fullNam return fullName, &activeVal } -// ParseUserFromPatchOperations extracts user data from SCIM patch operations func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName string, active *bool) { var givenName, familyName string @@ -555,6 +561,29 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st if op.Path != nil { path = op.Path.String() } + + // Handle empty path with value map (Okta style) + // e.g., { "op": "Replace", "value": { "active": false } } + if path == "" { + if valueMap, ok := op.Value.(map[string]any); ok { + if a, ok := valueMap["active"].(bool); ok { + active = &a + } + if name, ok := valueMap["displayName"].(string); ok { + fullName = name + } + if nameMap, ok := valueMap["name"].(map[string]any); ok { + if gn, ok := nameMap["givenName"].(string); ok { + givenName = gn + } + if fn, ok := nameMap["familyName"].(string); ok { + familyName = fn + } + } + } + continue + } + switch strings.ToLower(path) { case "active": if a, ok := op.Value.(bool); ok { @@ -584,14 +613,14 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st return fullName, active } -func membershipToResource(m *coredata.Membership, active bool) scim.Resource { +func membershipToResource(m *coredata.Membership) scim.Resource { return scim.Resource{ ID: m.ID.String(), ExternalID: optional.NewString(m.ID.String()), Attributes: scim.ResourceAttributes{ "userName": m.EmailAddress.String(), "displayName": m.FullName, - "active": active, + "active": m.State == coredata.MembershipStateActive, "name": map[string]any{ "formatted": m.FullName, }, diff --git a/pkg/iam/session_service.go b/pkg/iam/session_service.go index e9f0959e7..baa096dda 100644 --- a/pkg/iam/session_service.go +++ b/pkg/iam/session_service.go @@ -367,6 +367,10 @@ func (s SessionService) OpenSAMLChildSessionForOrganization( return fmt.Errorf("cannot load membership: %w", err) } + if membership.State == coredata.MembershipStateInactive { + return NewMembershipInactiveError(membership.ID) + } + tenantID := scope.GetTenantID() childSession = &coredata.Session{ ID: gid.New(tenantID, coredata.SessionEntityType), @@ -442,6 +446,10 @@ func (s SessionService) AssumeOrganizationSession( return fmt.Errorf("cannot load membership: %w", err) } + if membership.State == coredata.MembershipStateInactive { + return NewMembershipInactiveError(membership.ID) + } + samlConfig := &coredata.SAMLConfiguration{} err = samlConfig.LoadByOrganizationIDAndEmailDomain( ctx, diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index 87bee9136..3fa19f790 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -254,6 +254,13 @@ enum MembershipSource SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSCIM") } +enum MembershipState + @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipState") { + ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateActive") + INACTIVE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateInactive") +} + type Membership implements Node { id: ID! createdAt: Datetime! @@ -262,6 +269,7 @@ type Membership implements Node { organization: Organization @goField(forceResolver: true) role: MembershipRole! source: MembershipSource! + state: MembershipState! lastSession: Session @goField(forceResolver: true) @isViewer diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index df9810508..f9d577fcf 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -183,6 +183,7 @@ type ComplexityRoot struct { Profile func(childComplexity int) int Role func(childComplexity int) int Source func(childComplexity int) int + State func(childComplexity int) int } MembershipConnection struct { @@ -964,6 +965,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Membership.Source(childComplexity), true + case "Membership.state": + if e.complexity.Membership.State == nil { + break + } + + return e.complexity.Membership.State(childComplexity), true case "MembershipConnection.edges": if e.complexity.MembershipConnection.Edges == nil { @@ -2533,6 +2540,13 @@ enum MembershipSource SCIM @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipSourceSCIM") } +enum MembershipState + @goModel(model: "go.probo.inc/probo/pkg/coredata.MembershipState") { + ACTIVE @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateActive") + INACTIVE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.MembershipStateInactive") +} + type Membership implements Node { id: ID! createdAt: Datetime! @@ -2541,6 +2555,7 @@ type Membership implements Node { organization: Organization @goField(forceResolver: true) role: MembershipRole! source: MembershipSource! + state: MembershipState! lastSession: Session @goField(forceResolver: true) @isViewer @@ -5832,6 +5847,35 @@ func (ec *executionContext) fieldContext_Membership_source(_ context.Context, fi return fc, nil } +func (ec *executionContext) _Membership_state(ctx context.Context, field graphql.CollectedField, obj *types.Membership) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Membership_state, + func(ctx context.Context) (any, error) { + return obj.State, nil + }, + nil, + ec.marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Membership_state(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Membership", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type MembershipState does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Membership_lastSession(ctx context.Context, field graphql.CollectedField, obj *types.Membership) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6092,6 +6136,8 @@ func (ec *executionContext) fieldContext_MembershipEdge_node(_ context.Context, return ec.fieldContext_Membership_role(ctx, field) case "source": return ec.fieldContext_Membership_source(ctx, field) + case "state": + return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -8622,6 +8668,8 @@ func (ec *executionContext) fieldContext_Organization_viewerMembership(_ context return ec.fieldContext_Membership_role(ctx, field) case "source": return ec.fieldContext_Membership_source(ctx, field) + case "state": + return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -8777,6 +8825,8 @@ func (ec *executionContext) fieldContext_OrganizationSessionCreated_membership(_ return ec.fieldContext_Membership_role(ctx, field) case "source": return ec.fieldContext_Membership_source(ctx, field) + case "state": + return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -11175,6 +11225,8 @@ func (ec *executionContext) fieldContext_SCIMEvent_membership(_ context.Context, return ec.fieldContext_Membership_role(ctx, field) case "source": return ec.fieldContext_Membership_source(ctx, field) + case "state": + return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -12326,6 +12378,8 @@ func (ec *executionContext) fieldContext_UpdateMembershipPayload_membership(_ co return ec.fieldContext_Membership_role(ctx, field) case "source": return ec.fieldContext_Membership_source(ctx, field) + case "state": + return ec.fieldContext_Membership_state(ctx, field) case "lastSession": return ec.fieldContext_Membership_lastSession(ctx, field) case "permission": @@ -16471,6 +16525,11 @@ func (ec *executionContext) _Membership(ctx context.Context, sel ast.SelectionSe if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "state": + out.Values[i] = ec._Membership_state(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } case "lastSession": field := field @@ -20219,6 +20278,34 @@ var ( } ) +func (ec *executionContext) unmarshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState(ctx context.Context, v any) (coredata.MembershipState, error) { + tmp, err := graphql.UnmarshalString(v) + res := unmarshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState[tmp] + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState(ctx context.Context, sel ast.SelectionSet, v coredata.MembershipState) graphql.Marshaler { + _ = sel + res := graphql.MarshalString(marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState[v]) + if res == graphql.Null { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + } + return res +} + +var ( + unmarshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState = map[string]coredata.MembershipState{ + "ACTIVE": coredata.MembershipStateActive, + "INACTIVE": coredata.MembershipStateInactive, + } + marshalNMembershipState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐMembershipState = map[coredata.MembershipState]string{ + coredata.MembershipStateActive: "ACTIVE", + coredata.MembershipStateInactive: "INACTIVE", + } +) + func (ec *executionContext) unmarshalNOrderDirection2goᚗproboᚗincᚋproboᚋpkgᚋpageᚐOrderDirection(ctx context.Context, v any) (page.OrderDirection, error) { tmp, err := graphql.UnmarshalString(v) res := unmarshalNOrderDirection2goᚗproboᚗincᚋproboᚋpkgᚋpageᚐOrderDirection[tmp] diff --git a/pkg/server/api/connect/v1/types/membership.go b/pkg/server/api/connect/v1/types/membership.go index 5025957e3..e29b2d976 100644 --- a/pkg/server/api/connect/v1/types/membership.go +++ b/pkg/server/api/connect/v1/types/membership.go @@ -74,5 +74,6 @@ func NewMembership(membership *coredata.Membership) *Membership { }, Role: membership.Role, Source: membership.Source, + State: membership.State, } } diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index 8ce254012..2712923c3 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -211,6 +211,7 @@ type Membership struct { Organization *Organization `json:"organization,omitempty"` Role coredata.MembershipRole `json:"role"` Source coredata.MembershipSource `json:"source"` + State coredata.MembershipState `json:"state"` LastSession *Session `json:"lastSession,omitempty"` Permission bool `json:"permission"` }