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

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