Add membership state

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-01-05 15:49:33 +01:00
parent 431f2fd557
commit 7030d06aed
19 changed files with 450 additions and 149 deletions

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<e01416ecd129c81e31f5da90bf797ff7>>
* @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"
}
};
})();

View File

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

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<bd98e1c1ff5f9393deaec2a4aec1649c>>
* @generated SignedSource<<d7435d1c1ee31d5ddc186fd67fa9401f>>
* @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"
}
};
})();

View File

@@ -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<MemberListItemFragment$key>(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 (
<>
<Tr className={clsx(isRemoving && "opacity-60 pointer-events-none")}>
<Tr
className={clsx(
isRemoving && "opacity-60 pointer-events-none",
isInactive && "opacity-50"
)}
>
<Td>
<div className="font-semibold">{membership.profile.fullName}</div>
<div className="flex items-center gap-2">
<span className="font-semibold">{membership.profile.fullName}</span>
{isInactive && <Badge variant="neutral">{__("Inactive")}</Badge>}
</div>
</Td>
<Td>
<div className="flex items-center gap-2">
@@ -113,34 +124,36 @@ export function MemberListItem(props: {
</Td>
<Td>{new Date(membership.createdAt).toLocaleDateString()}</Td>
<Td noLink width={160} className="text-end">
<div
className="flex gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
{membership.canUpdate && canEditThisRole && (
<Button
variant="secondary"
onClick={() => setDialogOpen(true)}
disabled={dialogOpen}
icon={IconPencil}
aria-label={__("Edit role")}
/>
)}
{isRemoving ? (
<Spinner size={16} />
) : (
membership.canDelete &&
canEditThisRole && (
{!isInactive && (
<div
className="flex gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
{membership.canUpdate && canEditThisRole && (
<Button
variant="danger"
onClick={handleRemove}
disabled={isRemoving}
icon={IconTrashCan}
aria-label={__("Remove member")}
variant="secondary"
onClick={() => setDialogOpen(true)}
disabled={dialogOpen}
icon={IconPencil}
aria-label={__("Edit role")}
/>
)
)}
</div>
)}
{isRemoving ? (
<Spinner size={16} />
) : (
membership.canDelete &&
canEditThisRole && (
<Button
variant="danger"
onClick={handleRemove}
disabled={isRemoving}
icon={IconTrashCan}
aria-label={__("Remove member")}
/>
)
)}
</div>
)}
</Td>
</Tr>

View File

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

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,5 +74,6 @@ func NewMembership(membership *coredata.Membership) *Membership {
},
Role: membership.Role,
Source: membership.Source,
State: membership.State,
}
}

View File

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