@@ -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.
|
||||
//
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user