Add SCIM management

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-01-04 19:03:00 +01:00
parent b6799cbb01
commit cfc514c8e0
405 changed files with 4888 additions and 88927 deletions

View File

@@ -318,6 +318,16 @@ func (e ErrSAMLConfigurationEmailDomainAlreadyExists) Error() string {
return fmt.Sprintf("SAML configuration email domain %q already exists", e.EmailDomain)
}
type ErrNoSCIMConfigurationFound struct{ OrganizationID gid.GID }
func NewNoSCIMConfigurationFoundError(organizationID gid.GID) error {
return &ErrNoSCIMConfigurationFound{OrganizationID: organizationID}
}
func (e ErrNoSCIMConfigurationFound) Error() string {
return fmt.Sprintf("SCIM configuration not found for organization %q", e.OrganizationID)
}
// TenantAccessError is used by API recovery middleware to translate authorization/tenant failures
// into a consistent client-facing error response.
//

View File

@@ -64,4 +64,14 @@ const (
ActionSAMLConfigurationUpdate = "iam:saml-configuration:update"
ActionSAMLConfigurationDelete = "iam:saml-configuration:delete"
ActionSAMLConfigurationList = "iam:saml-configuration:list"
// SCIM Configuration actions
ActionSCIMConfigurationCreate = "iam:scim-configuration:create"
ActionSCIMConfigurationGet = "iam:scim-configuration:get"
ActionSCIMConfigurationUpdate = "iam:scim-configuration:update"
ActionSCIMConfigurationDelete = "iam:scim-configuration:delete"
// SCIM Event actions
ActionSCIMEventList = "iam:scim-event:list"
ActionSCIMEventGet = "iam:scim-event:get"
)

View File

@@ -153,6 +153,16 @@ var IAMOwnerPolicy = policy.NewPolicy(
policy.Allow("iam:saml-configuration:*").
WithSID("full-saml-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
// Full access to SCIM configuration management (scoped to own organization)
policy.Allow("iam:scim-configuration:*").
WithSID("full-scim-configuration-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
// Full access to SCIM event viewing (scoped to own organization)
policy.Allow("iam:scim-event:*").
WithSID("full-scim-event-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
).
WithDescription("Full IAM access for organization owners")
@@ -218,8 +228,25 @@ var IAMAdminPolicy = policy.NewPolicy(
ActionSAMLConfigurationDelete,
).
WithSID("deny-saml-management"),
// Can view SCIM configuration and events (scoped to own organization)
policy.Allow(
ActionSCIMConfigurationGet,
ActionSCIMEventList,
ActionSCIMEventGet,
).
WithSID("scim-admin-view-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
// Cannot manage SCIM configurations (only owner can)
policy.Deny(
ActionSCIMConfigurationCreate,
ActionSCIMConfigurationUpdate,
ActionSCIMConfigurationDelete,
).
WithSID("deny-scim-management"),
).
WithDescription("IAM admin access - can manage members but cannot delete organization or manage SAML")
WithDescription("IAM admin access - can manage members but cannot delete organization or manage SAML/SCIM")
// IAMViewerPolicy defines permissions for organization viewers.
var IAMViewerPolicy = policy.NewPolicy(

View File

@@ -28,6 +28,7 @@ import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filevalidation"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/scim"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/statelesstoken"
@@ -1089,6 +1090,275 @@ func (s OrganizationService) CountSAMLConfigurations(
return count, err
}
func (s OrganizationService) ListSCIMEvents(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.SCIMEventOrderField],
) (*page.Page[*coredata.SCIMEvent, coredata.SCIMEventOrderField], error) {
var (
scope = coredata.NewScopeFromObjectID(organizationID)
scimEvents = coredata.SCIMEvents{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := scimEvents.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor)
if err != nil {
return fmt.Errorf("cannot load scim events: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(scimEvents, cursor), nil
}
func (s OrganizationService) CountSCIMEvents(
ctx context.Context,
organizationID gid.GID,
) (int, error) {
var (
scope = coredata.NewScopeFromObjectID(organizationID)
count int
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
scimEvents := coredata.SCIMEvents{}
count, err = scimEvents.CountByOrganizationID(ctx, conn, scope, organizationID)
if err != nil {
return fmt.Errorf("cannot count scim events: %w", err)
}
return nil
},
)
return count, err
}
func (s OrganizationService) GetSCIMConfiguration(
ctx context.Context,
organizationID gid.GID,
) (*coredata.SCIMConfiguration, error) {
var (
scope = coredata.NewScopeFromObjectID(organizationID)
config = &coredata.SCIMConfiguration{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := config.LoadByOrganizationID(ctx, conn, scope, organizationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewNoSCIMConfigurationFoundError(organizationID)
}
return fmt.Errorf("cannot load SCIM configuration: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return config, nil
}
func (s OrganizationService) CreateSCIMConfiguration(
ctx context.Context,
organizationID gid.GID,
) (*coredata.SCIMConfiguration, string, error) {
token, err := scim.GenerateToken()
if err != nil {
return nil, "", err
}
hashedToken := scim.HashToken(token)
now := time.Now()
config := &coredata.SCIMConfiguration{
ID: gid.New(organizationID.TenantID(), coredata.SCIMConfigurationEntityType),
OrganizationID: organizationID,
HashedToken: hashedToken,
CreatedAt: now,
UpdatedAt: now,
}
scope := coredata.NewScopeFromObjectID(organizationID)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
err := config.Insert(ctx, tx, scope)
if err != nil {
if err == coredata.ErrResourceAlreadyExists {
return scim.NewSCIMConfigurationAlreadyExistsError(organizationID)
}
return fmt.Errorf("cannot insert SCIM configuration: %w", err)
}
return nil
},
)
if err != nil {
return nil, "", err
}
return config, token, nil
}
func (s OrganizationService) DeleteSCIMConfiguration(
ctx context.Context,
organizationID gid.GID,
configID gid.GID,
) error {
scope := coredata.NewScopeFromObjectID(configID)
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
config := &coredata.SCIMConfiguration{}
err := config.LoadByID(ctx, tx, scope, configID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return scim.NewSCIMConfigurationNotFoundError(configID)
}
return fmt.Errorf("cannot load SCIM configuration: %w", err)
}
if config.OrganizationID != organizationID {
return scim.NewSCIMConfigurationNotFoundError(configID)
}
memberships := &coredata.Memberships{}
err = memberships.ResetSCIMSources(ctx, tx, scope, config.OrganizationID)
if err != nil {
return fmt.Errorf("cannot reset membership sources: %w", err)
}
err = config.Delete(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot delete SCIM configuration: %w", err)
}
return nil
},
)
}
func (s OrganizationService) RegenerateSCIMToken(
ctx context.Context,
organizationID gid.GID,
configID gid.GID,
) (*coredata.SCIMConfiguration, string, error) {
token, err := scim.GenerateToken()
if err != nil {
return nil, "", err
}
hashedToken := scim.HashToken(token)
config := &coredata.SCIMConfiguration{}
scope := coredata.NewScopeFromObjectID(configID)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
err := config.LoadByID(ctx, tx, scope, configID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return scim.NewSCIMConfigurationNotFoundError(configID)
}
return fmt.Errorf("cannot load SCIM configuration: %w", err)
}
if config.OrganizationID != organizationID {
return scim.NewSCIMConfigurationNotFoundError(configID)
}
config.HashedToken = hashedToken
config.UpdatedAt = time.Now()
err = config.Update(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot update SCIM configuration: %w", err)
}
return nil
},
)
if err != nil {
return nil, "", err
}
return config, token, nil
}
func (s OrganizationService) ListSCIMEventsByConfigID(
ctx context.Context,
scimConfigurationID gid.GID,
cursor *page.Cursor[coredata.SCIMEventOrderField],
) (*page.Page[*coredata.SCIMEvent, coredata.SCIMEventOrderField], error) {
var (
scope = coredata.NewScopeFromObjectID(scimConfigurationID)
scimEvents = coredata.SCIMEvents{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := scimEvents.LoadBySCIMConfigurationID(ctx, conn, scope, scimConfigurationID, cursor)
if err != nil {
return fmt.Errorf("cannot load scim events: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(scimEvents, cursor), nil
}
func (s OrganizationService) CountSCIMEventsByConfigID(
ctx context.Context,
scimConfigurationID gid.GID,
) (int, error) {
var (
scope = coredata.NewScopeFromObjectID(scimConfigurationID)
count int
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
scimEvents := coredata.SCIMEvents{}
count, err = scimEvents.CountBySCIMConfigurationID(ctx, conn, scope, scimConfigurationID)
if err != nil {
return fmt.Errorf("cannot count scim events: %w", err)
}
return nil
},
)
return count, err
}
func (s OrganizationService) CreateSAMLConfiguration(
ctx context.Context,
organizationID gid.GID,

View File

@@ -322,27 +322,40 @@ func (s *Service) HandleAssertion(
}
}
if role != nil {
membership.Role = *role
membership.UpdatedAt = now
if membership.Source != coredata.MembershipSourceSCIM {
needsUpdate := false
err = membership.Update(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
if role != nil {
membership.Role = *role
membership.UpdatedAt = now
needsUpdate = true
}
}
memberProfile := &coredata.MembershipProfile{}
err = memberProfile.LoadByMembershipID(ctx, tx, scope, membership.ID)
if err != nil {
return fmt.Errorf("cannot load membership profile: %w", err)
}
if membership.Source == coredata.MembershipSourceManual {
membership.Source = coredata.MembershipSourceSAML
membership.UpdatedAt = now
needsUpdate = true
}
memberProfile.FullName = fullname
memberProfile.UpdatedAt = now
err = memberProfile.Update(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot update membership profile: %w", err)
if needsUpdate {
err = membership.Update(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
}
}
memberProfile := &coredata.MembershipProfile{}
err = memberProfile.LoadByMembershipID(ctx, tx, scope, membership.ID)
if err != nil {
return fmt.Errorf("cannot load membership profile: %w", err)
}
memberProfile.FullName = fullname
memberProfile.UpdatedAt = now
err = memberProfile.Update(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot update membership profile: %w", err)
}
}
return nil

View File

@@ -272,3 +272,59 @@ func (s *Service) GetPersonalAPIKey(ctx context.Context, personalAPIKeyID gid.GI
return personalAPIKey, nil
}
func (s *Service) GetSCIMConfiguration(ctx context.Context, scimConfigurationID gid.GID) (*coredata.SCIMConfiguration, error) {
var (
scope = coredata.NewScopeFromObjectID(scimConfigurationID)
scimConfiguration = &coredata.SCIMConfiguration{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := scimConfiguration.LoadByID(ctx, conn, scope, scimConfigurationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return scim.NewSCIMConfigurationNotFoundError(scimConfigurationID)
}
return fmt.Errorf("cannot load SCIM configuration: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return scimConfiguration, nil
}
func (s *Service) GetSCIMEvent(ctx context.Context, scimEventID gid.GID) (*coredata.SCIMEvent, error) {
var (
scope = coredata.NewScopeFromObjectID(scimEventID)
scimEvent = &coredata.SCIMEvent{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := scimEvent.LoadByID(ctx, conn, scope, scimEventID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return fmt.Errorf("SCIM event not found: %s", scimEventID)
}
return fmt.Errorf("cannot load SCIM event: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return scimEvent, nil
}