Rewrite identity and access management

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-12-03 19:23:15 +01:00
parent 4ed3f5a067
commit 74fc3b8cd1
201 changed files with 32895 additions and 23649 deletions

View File

@@ -0,0 +1,204 @@
// 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 iam
import (
"context"
"errors"
"fmt"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type (
AccessManagementService struct {
*Service
}
)
func NewAccessManagementService(svc *Service) *AccessManagementService {
return &AccessManagementService{Service: svc}
}
// Authorize implements Model 2 authorization:
// - principalID is the actor (User now; later service accounts)
// - credentialID is an optional credential (UserAPIKey now)
// - intersection semantics: actor must be allowed AND credential (if present) must be allowed.
//
// Entity scope:
// - Global/self-owned entities (User/Session/UserAPIKey) are authorized via ownership checks only (no global admin).
// - Organization-scoped entities are authorized via membership lookups that derive organization_id from entityID.
func (s *AccessManagementService) Authorize(ctx context.Context, principalID gid.GID, credentialID *gid.GID, entityID gid.GID, action Action) error {
requiredRoles := GetPermissionsForAction(entityID.EntityType(), action)
if requiredRoles == nil {
entityModel, _ := coredata.EntityModel(entityID.EntityType())
return NewNoPermissionsDefinedError(entityModel, action)
}
switch principalID.EntityType() {
case coredata.UserEntityType:
// ok
default:
return NewUnsupportedPrincipalTypeError(principalID.EntityType())
}
return s.pg.WithConn(ctx, func(conn pg.Conn) error {
// Global/self-owned path
switch entityID.EntityType() {
case coredata.UserEntityType:
if entityID != principalID {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
return nil
case coredata.SessionEntityType:
sess := &coredata.Session{}
if err := sess.LoadByID(ctx, conn, entityID); err != nil {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
if sess.UserID != principalID {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
return nil
case coredata.UserAPIKeyEntityType:
key := &coredata.UserAPIKey{}
if err := key.LoadByID(ctx, conn, entityID); err != nil {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
if key.UserID != principalID {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
return nil
}
// Organization-scoped path (derive org via joins)
scope := coredata.NewScope(entityID.TenantID())
actorRoleName, err := s.loadUserRoleForEntity(ctx, conn, scope, principalID, entityID)
if err != nil || !requiredRoleNamesContain(actorRoleName, requiredRoles) {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
// Optional credential restriction (intersection)
if credentialID != nil {
switch credentialID.EntityType() {
case coredata.UserAPIKeyEntityType:
// Defensive check: credential must belong to actor
apiKey := &coredata.UserAPIKey{}
if err := apiKey.LoadByID(ctx, conn, *credentialID); err != nil {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
if apiKey.UserID != principalID {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
keyRoleName, err := s.loadAPIKeyRoleForEntity(ctx, conn, scope, *credentialID, entityID)
if err != nil || !requiredRoleNamesContain(keyRoleName, requiredRoles) {
return NewInsufficientPermissionsError(principalID, entityID, action)
}
default:
return NewUnsupportedPrincipalTypeError(credentialID.EntityType())
}
}
return nil
})
}
func (s *AccessManagementService) loadUserRoleForEntity(
ctx context.Context,
conn pg.Conn,
scope coredata.Scoper,
userID gid.GID,
entityID gid.GID,
) (Role, error) {
var m coredata.Membership
if err := m.LoadRoleByUserAndEntityID(ctx, conn, scope, userID, entityID); err != nil {
// Do not leak existence details
if errors.Is(err, coredata.ErrResourceNotFound) {
return "", err
}
return "", err
}
return Role(m.Role.String()), nil
}
func (s *AccessManagementService) loadAPIKeyRoleForEntity(
ctx context.Context,
conn pg.Conn,
scope coredata.Scoper,
apiKeyID gid.GID,
entityID gid.GID,
) (Role, error) {
var akm coredata.UserAPIKeyMembership
if err := akm.LoadRoleByAPIKeyAndEntityID(ctx, conn, scope, apiKeyID, entityID); err != nil {
return "", err
}
// Strict API key semantics: FULL only matches RoleFull explicitly.
switch akm.Role {
case coredata.APIRoleFull:
return RoleFull, nil
default:
return "", fmt.Errorf("unsupported api key role: %s", akm.Role)
}
}
// requiredRoleNamesContain is a temporary evaluator for the current in-code permissions registry
// (`Permissions` in `permissions.go`). In the future this becomes policy-document evaluation
// where the role name resolves to policy statements.
func requiredRoleNamesContain(roleName Role, required []Role) bool {
for _, r := range required {
if r == roleName {
return true
}
}
return false
}
// func (s AccountService) AllAccessibleTenants(ctx context.Context, identityID gid.GID) ([]gid.TenantID, error) {
// var tenants []gid.TenantID
// err := s.pg.WithConn(
// ctx,
// func(conn pg.Conn) error {
// memberships := coredata.Memberships{}
// orderBy := page.OrderBy[coredata.MembershipOrderField]{
// Field: coredata.MembershipOrderFieldCreatedAt,
// Direction: page.OrderDirectionDesc,
// }
// cursor := page.NewCursor(1000, nil, page.Head, orderBy)
// err := memberships.LoadByUserID(ctx, conn, coredata.NewNoScope(), identityID, cursor)
// if err != nil {
// return fmt.Errorf("cannot load memberships: %w", err)
// }
// for _, membership := range memberships {
// tenants = append(tenants, membership.ID.TenantID())
// }
// return nil
// },
// )
// if err != nil {
// return nil, err
// }
// return tenants, nil
// }

748
pkg/iam/account_service.go Normal file
View File

@@ -0,0 +1,748 @@
// 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 iam
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/validator"
)
type (
AccountService struct {
*Service
}
UserAPIKeyTokenData struct {
Version int `json:"v"`
KeyID gid.GID `json:"kid"`
PrincipalID gid.GID `json:"pid"`
IssuedAt time.Time `json:"iat"`
}
EmailConfirmationData struct {
UserID gid.GID `json:"uid"`
Email mail.Addr `json:"email"`
}
)
const (
TokenTypeEmailConfirmation = "email_confirmation"
)
func NewAccountService(svc *Service) *AccountService {
return &AccountService{Service: svc}
}
type ChangeEmailRequest struct {
NewEmail mail.Addr
Password string
}
func (req ChangeEmailRequest) Validate() error {
v := validator.New()
v.Check(req.Password, "password", validator.NotEmpty(), validator.MaxLen(255)) // We cannot use PasswordValidator here because legacy password may not be aligned with the current password policy, therefore we at least enforce a maximum length to mitigate DDoS attacks.
return v.Error()
}
func (s AccountService) ChangeEmail(ctx context.Context, identityID gid.GID, req *ChangeEmailRequest) error {
if err := req.Validate(); err != nil {
return fmt.Errorf("invalid request: %w", err)
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
24*time.Hour,
EmailConfirmationData{UserID: identityID, Email: req.NewEmail},
)
if err != nil {
return fmt.Errorf("cannot generate confirmation token: %w", err)
}
base, err := baseurl.Parse(s.baseURL)
if err != nil {
return fmt.Errorf("cannot parse base URL: %w", err)
}
confirmationUrl, err := base.
WithPath("/auth/confirm-email").
WithQuery("token", confirmationToken).
String()
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByID(ctx, tx, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
isPasswordMatch, err := s.hp.ComparePasswordAndHash([]byte(req.Password), user.HashedPassword)
if err != nil {
return fmt.Errorf("cannot compare password: %w", err)
}
if !isPasswordMatch {
return NewInvalidPasswordError("invalid password")
}
user.EmailAddress = req.NewEmail
user.EmailAddressVerified = false
user.UpdatedAt = time.Now()
err = user.Update(ctx, tx)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
subject, textBody, htmlBody, err := emails.RenderConfirmEmail(
s.baseURL,
user.FullName,
confirmationUrl,
)
if err != nil {
return fmt.Errorf("cannot render confirmation email: %w", err)
}
confirmationEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
subject,
textBody,
htmlBody,
)
err = confirmationEmail.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert confirmation email: %w", err)
}
return nil
},
)
}
func (s AccountService) VerifyEmail(ctx context.Context, token string) error {
payload, err := statelesstoken.ValidateToken[EmailConfirmationData](s.tokenSecret, TokenTypeEmailConfirmation, token)
if err != nil {
return NewInvalidTokenError()
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByID(ctx, tx, payload.Data.UserID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(payload.Data.UserID)
}
return fmt.Errorf("cannot load user: %w", err)
}
if user.EmailAddress != payload.Data.Email {
return NewEmailVerificationMismatchError()
}
if user.EmailAddressVerified {
return NewEmailAlreadyVerifiedError()
}
user.EmailAddressVerified = true
user.UpdatedAt = time.Now()
err = user.Update(ctx, tx)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
return nil
},
)
}
func (s *AccountService) AcceptInvitation(
ctx context.Context,
identityID gid.GID,
invitationID gid.GID,
) (*coredata.Membership, error) {
var (
now = time.Now()
membership = &coredata.Membership{}
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := coredata.User{}
invitation := coredata.Invitation{}
err := user.LoadByID(ctx, tx, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
err = invitation.LoadByID(ctx, tx, coredata.NewNoScope(), invitationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.Email != user.EmailAddress {
return NewInvitationNotFoundError(invitationID)
}
if invitation.AcceptedAt != nil {
return NewInvitationAlreadyAcceptedError(invitationID)
}
if invitation.ExpiresAt.Before(now) {
return NewInvitationExpiredError(invitationID)
}
tenantID := invitation.OrganizationID.TenantID()
scope := coredata.NewScope(invitation.OrganizationID.TenantID())
membership = &coredata.Membership{
ID: gid.New(tenantID, coredata.MembershipEntityType),
UserID: identityID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
err = membership.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot create membership: %w", err)
}
invitation.AcceptedAt = &now
err = invitation.Update(ctx, tx, scope)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot update invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *AccountService) ListPendingInvitations(
ctx context.Context,
identityID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
identity := coredata.User{}
err := identity.LoadByID(ctx, conn, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load identity: %w", err)
}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
err = invitations.LoadByIdentityID(ctx, conn, coredata.NewNoScope(), identity.EmailAddress, cursor, onlyPending)
if err != nil {
return fmt.Errorf("cannot load invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
func (s *AccountService) CountPendingInvitations(
ctx context.Context,
identityID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
identity := coredata.User{}
err := identity.LoadByID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
invitations := coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
count, err = invitations.CountByEmail(ctx, conn, identity.EmailAddress, onlyPending)
if err != nil {
return fmt.Errorf("cannot count pending invitations: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s *AccountService) ListMemberships(
ctx context.Context,
identityID gid.GID,
cursor *page.Cursor[coredata.MembershipOrderField],
) (*page.Page[*coredata.Membership, coredata.MembershipOrderField], error) {
var memberships coredata.Memberships
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := memberships.LoadByUserID(ctx, conn, coredata.NewNoScope(), identityID, cursor)
if err != nil {
return fmt.Errorf("cannot load memberships: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(memberships, cursor), nil
}
func (s *AccountService) CountMemberships(
ctx context.Context,
identityID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
memberships := coredata.Memberships{}
count, err = memberships.CountByUserID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot count memberships: %w", err)
}
return nil
},
)
if err != nil {
return 0, err
}
return count, nil
}
func (s AccountService) ChangePassword(ctx context.Context, identityID gid.GID, req *ChangePasswordRequest) error {
if err := req.Validate(); err != nil {
return fmt.Errorf("invalid request: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByID(ctx, tx, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
isLegacyPasswordMatch, err := s.hp.ComparePasswordAndHash([]byte(req.CurrentPassword), user.HashedPassword)
if err != nil {
return fmt.Errorf("cannot compare legacy password: %w", err)
}
if !isLegacyPasswordMatch {
return NewInvalidPasswordError("invalid current password")
}
newPasswordHash, err := s.hp.HashPassword([]byte(req.NewPassword))
if err != nil {
return fmt.Errorf("cannot hash new password: %w", err)
}
user.HashedPassword = newPasswordHash
user.UpdatedAt = time.Now()
err = user.Update(ctx, tx)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
// TODO: email to notify user that their password has been changed
return nil
},
)
}
func (s AccountService) CountSessions(ctx context.Context, identityID gid.GID) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
sessions := coredata.Sessions{}
count, err = sessions.CountByUserID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot count sessions: %w", err)
}
return nil
},
)
return count, err
}
func (s AccountService) ListSessions(
ctx context.Context,
identityID gid.GID,
cursor *page.Cursor[coredata.SessionOrderField],
) (*page.Page[*coredata.Session, coredata.SessionOrderField], error) {
var sessions coredata.Sessions
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := sessions.LoadByUserID(ctx, conn, identityID, cursor)
if err != nil {
return fmt.Errorf("cannot load sessions: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(sessions, cursor), nil
}
func (s AccountService) GetIdentity(ctx context.Context, identityID gid.GID) (*coredata.User, error) {
user := &coredata.User{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := user.LoadByID(ctx, conn, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return user, nil
}
func (s AccountService) ListPersonalAPIKeys(
ctx context.Context,
identityID gid.GID,
cursor *page.Cursor[coredata.UserAPIKeyOrderField],
) (*page.Page[*coredata.UserAPIKey, coredata.UserAPIKeyOrderField], error) {
var personalAccessTokens coredata.UserAPIKeys
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := personalAccessTokens.LoadByUserID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot load personal access tokens: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(personalAccessTokens, cursor), nil
}
func (s AccountService) CountPersonalAPIKeys(ctx context.Context, identityID gid.GID) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
personalAccessTokens := coredata.UserAPIKeys{}
count, err = personalAccessTokens.CountByUserID(ctx, conn, identityID)
if err != nil {
return fmt.Errorf("cannot count personal access tokens: %w", err)
}
return nil
},
)
return count, err
}
func (s AccountService) GetIdentityForMembership(ctx context.Context, membershipID gid.GID) (*coredata.User, error) {
var (
scope = coredata.NewScopeFromObjectID(membershipID)
identity = &coredata.User{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
membership := &coredata.Membership{}
err := membership.LoadByID(ctx, conn, scope, membershipID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewMembershipNotFoundError(membershipID)
}
return fmt.Errorf("cannot load membership: %w", err)
}
err = identity.LoadByID(ctx, conn, membership.UserID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(membership.UserID)
}
return fmt.Errorf("cannot load identity: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return identity, nil
}
func (s *AccountService) CreatePersonalAPIKey(
ctx context.Context,
identityID gid.GID,
name string,
expiresAt time.Time,
) (*coredata.UserAPIKey, string, error) {
var (
userAPIKey *coredata.UserAPIKey
token string
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) (err error) {
now := time.Now()
userAPIKey = &coredata.UserAPIKey{
ID: gid.New(gid.NilTenant, coredata.UserAPIKeyEntityType),
UserID: identityID,
Name: name,
ExpiresAt: expiresAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := userAPIKey.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user api key: %w", err)
}
token, err = statelesstoken.NewDeterministicToken(
s.tokenSecret,
TokenTypeAPIKey,
userAPIKey.ExpiresAt,
userAPIKey.CreatedAt,
UserAPIKeyTokenData{
Version: 2,
KeyID: userAPIKey.ID,
PrincipalID: identityID,
IssuedAt: userAPIKey.CreatedAt,
},
)
if err != nil {
return fmt.Errorf("cannot generate user api key token: %w", err)
}
return nil
},
)
if err != nil {
return nil, "", err
}
return userAPIKey, token, nil
}
func (s *AccountService) DeletePersonalAPIKey(
ctx context.Context,
identityID gid.GID,
userAPIKeyID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
userAPIKey := &coredata.UserAPIKey{}
err := userAPIKey.LoadByID(ctx, tx, userAPIKeyID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserAPIKeyNotFoundError(userAPIKeyID)
}
return fmt.Errorf("cannot load user api key: %w", err)
}
if userAPIKey.UserID != identityID {
return NewUserAPIKeyNotFoundError(userAPIKeyID)
}
err = userAPIKey.Delete(ctx, tx)
if err != nil {
return fmt.Errorf("cannot delete user api key: %w", err)
}
return nil
},
)
}
func (s AccountService) ListOrganizations(ctx context.Context, identityID gid.GID) ([]*coredata.Organization, error) {
var organizations coredata.Organizations
orderBy := page.OrderBy[coredata.OrganizationOrderField]{
Field: coredata.OrganizationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := page.NewCursor(1000, nil, page.Head, orderBy)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := organizations.LoadByUserID(ctx, conn, coredata.NewNoScope(), identityID, cursor)
if err != nil {
return fmt.Errorf("cannot load organizations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return organizations, nil
}
// func (s AccountService) AllAccessibleTenants(ctx context.Context, identityID gid.GID) ([]gid.TenantID, error) {
// var tenants []gid.TenantID
// err := s.pg.WithConn(
// ctx,
// func(conn pg.Conn) error {
// memberships := coredata.Memberships{}
// orderBy := page.OrderBy[coredata.MembershipOrderField]{
// Field: coredata.MembershipOrderFieldCreatedAt,
// Direction: page.OrderDirectionDesc,
// }
// cursor := page.NewCursor(1000, nil, page.Head, orderBy)
// err := memberships.LoadByUserID(ctx, conn, coredata.NewNoScope(), identityID, cursor)
// if err != nil {
// return fmt.Errorf("cannot load memberships: %w", err)
// }
// for _, membership := range memberships {
// tenants = append(tenants, membership.ID.TenantID())
// }
// return nil
// },
// )
// if err != nil {
// return nil, err
// }
// return tenants, nil
// }

View File

@@ -0,0 +1,41 @@
// 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 iam
type (
Action string
)
const (
ActionIAMOrganizationCreate Action = "iam:organization:create"
ActionIAMOrganizationUpdate Action = "iam:organization:update"
ActionIAMOrganizationGet Action = "iam:organization:get"
ActionIAMOrganizationDelete Action = "iam:organization:delete"
ActionIAMOrganizationList Action = "iam:organization:list"
ActionIAMOrganizationInviteMember Action = "iam:organization:invite-member"
ActionIAMOrganizationRemoveMember Action = "iam:organization:remove-member"
ActionIAMOrganizationListMembers Action = "iam:organization:list-members"
ActionIAMOrganizationListInvitations Action = "iam:organization:list-invitations"
ActionIAMIdentityListMemberships Action = "iam:identity:list-memberships"
ActionIAMIdentityListInvitations Action = "iam:identity:list-invitations"
ActionIAMIdentityListSessions Action = "iam:identity:list-sessions"
ActionIAMSessionClose Action = "iam:identity:close-session"
ActionIAMSessionRevoke Action = "iam:identity:revoke-session"
ActionIAMSessionRevokeAll Action = "iam:identity:revoke-all-sessions"
ActionIAMInvitationAccept Action = "iam:identity:accept-invitation"
)

78
pkg/iam/api_key.go Normal file
View File

@@ -0,0 +1,78 @@
// 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 iam
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type (
APIKeyService struct {
*Service
}
)
func NewAPIKeyService(svc *Service) *APIKeyService {
return &APIKeyService{Service: svc}
}
func (s *APIKeyService) GetAPIKey(ctx context.Context, keyID gid.GID) (*coredata.UserAPIKey, error) {
var (
apiKey = &coredata.UserAPIKey{}
now = time.Now()
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := apiKey.LoadByID(ctx, tx, keyID); err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserAPIKeyNotFoundError(keyID)
}
}
if apiKey.ExpireReason != nil {
return NewUserAPIKeyExpiredError(keyID)
}
if now.After(apiKey.ExpiresAt) {
apiKey.ExpireReason = ref.Ref(coredata.ExpireReasonIdleTimeout)
apiKey.ExpiresAt = now
apiKey.UpdatedAt = now
if err := apiKey.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update user api key: %w", err)
}
return NewUserAPIKeyExpiredError(keyID)
}
return nil
},
)
if err != nil {
return nil, err
}
return apiKey, nil
}

488
pkg/iam/auth_service.go Normal file
View File

@@ -0,0 +1,488 @@
// 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 iam
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/validator"
)
type (
AuthService struct {
*Service
}
ResetPasswordRequest struct {
Token string
Password string
}
ChangePasswordRequest struct {
CurrentPassword string
NewPassword string
}
CreateIdentityFromInvitationRequest struct {
InvitationToken string
Password string
FullName string
}
CreateIdentityWithPasswordRequest struct {
Email mail.Addr
Password string
FullName string
}
PasswordResetData struct {
Email mail.Addr `json:"email"`
}
)
const (
TokenTypeOrganizationInvitation = "organization_invitation"
TokenTypePasswordReset = "password_reset"
)
func NewAuthService(svc *Service) *AuthService {
return &AuthService{Service: svc}
}
func (req CreateIdentityFromInvitationRequest) Validate() error {
v := validator.New()
v.Check(req.InvitationToken, "invitationToken", validator.NotEmpty())
v.Check(req.FullName, "fullName", validator.NotEmpty(), validator.MinLen(1), validator.MaxLen(255))
v.Check(req.Password, "password", PasswordValidator())
return v.Error()
}
func (req ResetPasswordRequest) Validate() error {
v := validator.New()
v.Check(req.Token, "token", validator.NotEmpty())
v.Check(req.Password, "password", PasswordValidator())
return v.Error()
}
func (req ChangePasswordRequest) Validate() error {
v := validator.New()
// We cannot use PasswordValidator here because legacy password may not be aligned with the current password
// policy, therefore we at least enforce a maximum length to mitigate DDoS attacks.
v.Check(req.CurrentPassword, "currentPassword", validator.NotEmpty(), validator.MaxLen(255))
v.Check(req.NewPassword, "newPassword", PasswordValidator())
return v.Error()
}
func (req CreateIdentityWithPasswordRequest) Validate() error {
v := validator.New()
v.Check(req.FullName, "fullName", validator.NotEmpty(), validator.MinLen(1), validator.MaxLen(255))
v.Check(req.Password, "password", PasswordValidator())
return v.Error()
}
func (s *AuthService) CreateIdentityFromInvitation(
ctx context.Context,
req *CreateIdentityFromInvitationRequest,
) (*coredata.User, *coredata.Session, error) {
if err := req.Validate(); err != nil {
return nil, nil, fmt.Errorf("invalid request: %w", err)
}
payload, err := statelesstoken.ValidateToken[InvitationTokenData](s.tokenSecret, TokenTypeOrganizationInvitation, req.InvitationToken)
if err != nil {
return nil, nil, NewInvalidTokenError()
}
var (
scope = coredata.NewScopeFromObjectID(payload.Data.InvitationID)
invitation = &coredata.Invitation{}
user = &coredata.User{}
session = &coredata.Session{}
now = time.Now()
)
hashedPassword, err := s.hp.HashPassword([]byte(req.Password))
if err != nil {
return nil, nil, fmt.Errorf("cannot hash password: %w", err)
}
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
err := invitation.LoadByID(ctx, tx, scope, payload.Data.InvitationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(payload.Data.InvitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return NewInvitationAlreadyAcceptedError(payload.Data.InvitationID)
}
if invitation.ExpiresAt.Before(now) {
return NewInvitationExpiredError(payload.Data.InvitationID)
}
user = &coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: invitation.Email,
HashedPassword: hashedPassword,
EmailAddressVerified: true,
FullName: invitation.FullName,
CreatedAt: now,
UpdatedAt: now,
}
err = user.Insert(ctx, tx)
if err != nil {
if err == coredata.ErrResourceAlreadyExists {
return NewUserAlreadyExistsError(invitation.Email)
}
return fmt.Errorf("cannot insert user: %w", err)
}
session = coredata.NewRootSession(user.ID, s.sessionDuration)
err = session.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return user, session, nil
}
func (s AuthService) ResetPassword(
ctx context.Context,
req *ResetPasswordRequest,
) error {
if err := req.Validate(); err != nil {
return fmt.Errorf("invalid request: %w", err)
}
payload, err := statelesstoken.ValidateToken[PasswordResetData](s.tokenSecret, TokenTypePasswordReset, req.Token)
if err != nil {
return NewInvalidTokenError()
}
hashedPassword, err := s.hp.HashPassword([]byte(req.Password))
if err != nil {
return fmt.Errorf("cannot hash password: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByEmail(ctx, tx, payload.Data.Email)
if err != nil {
if err == coredata.ErrResourceNotFound {
return nil // Don't leak information about non-existent users
}
return fmt.Errorf("cannot load user: %w", err)
}
user.HashedPassword = hashedPassword
user.UpdatedAt = time.Now()
err = user.Update(ctx, tx)
if err != nil {
if err == coredata.ErrResourceNotFound {
return nil // Don't leak information about non-existent users
}
return fmt.Errorf("cannot update user: %w", err)
}
return nil
},
)
}
func (s AuthService) SendPasswordResetInstructionByEmail(
ctx context.Context,
email mail.Addr,
) error {
token, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypePasswordReset,
s.passwordResetTokenValidity,
PasswordResetData{Email: email},
)
if err != nil {
return fmt.Errorf("cannot generate password reset token: %w", err)
}
base, err := baseurl.Parse(s.baseURL)
if err != nil {
return fmt.Errorf("cannot parse base URL: %w", err)
}
resetPasswordUrl := base.
WithPath("/auth/reset-password").
WithQuery("token", token).
MustString()
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByEmail(ctx, tx, email); err != nil {
if err == coredata.ErrResourceNotFound {
return nil // Don't leak information about non-existent users
}
return fmt.Errorf("cannot load user: %w", err)
}
subject, textBody, htmlBody, err := emails.RenderPasswordReset(
s.baseURL,
user.FullName,
resetPasswordUrl,
)
if err != nil {
return fmt.Errorf("cannot render password reset email: %w", err)
}
passwordResetEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
subject,
textBody,
htmlBody,
)
err = passwordResetEmail.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
return nil
},
)
}
func (s AuthService) CreateIdentityWithPassword(
ctx context.Context,
req *CreateIdentityWithPasswordRequest,
) (*coredata.User, *coredata.Session, error) {
if s.disableSignup { // TODO Rename this one to disableSignup
return nil, nil, NewErrSignupDisabled()
}
if err := req.Validate(); err != nil {
return nil, nil, fmt.Errorf("invalid request: %w", err)
}
hashedPassword, err := s.hp.HashPassword([]byte(req.Password))
if err != nil {
return nil, nil, fmt.Errorf("cannot hash password: %w", err)
}
var (
now = time.Now()
user = &coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: req.Email,
HashedPassword: hashedPassword,
EmailAddressVerified: false,
FullName: req.FullName,
CreatedAt: now,
UpdatedAt: now,
}
session = &coredata.Session{
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
UserID: user.ID,
Data: coredata.SessionData{
PasswordAuthenticated: true,
SAMLAuthenticatedOrgs: make(map[string]coredata.SAMLAuthInfo),
},
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days, TODO must to be hardcoded here
CreatedAt: now,
UpdatedAt: now,
}
)
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
24*time.Hour,
EmailConfirmationData{UserID: user.ID, Email: user.EmailAddress},
)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate confirmation token: %w", err)
}
base, err := baseurl.Parse(s.baseURL)
if err != nil {
return nil, nil, fmt.Errorf("cannot parse base URL: %w", err)
}
confirmationUrl, err := base.
WithPath("/auth/confirm-email").
WithQuery("token", confirmationToken).
String()
if err != nil {
return nil, nil, fmt.Errorf("cannot build confirmation URL: %w", err)
}
subject, textBody, htmlBody, err := emails.RenderConfirmEmail(
s.baseURL,
user.FullName,
confirmationUrl,
)
if err != nil {
return nil, nil, fmt.Errorf("cannot render confirmation email: %w", err)
}
confirmationEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
subject,
textBody,
htmlBody,
)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
err := user.Insert(ctx, tx)
if err != nil {
if err == coredata.ErrResourceAlreadyExists {
return NewUserAlreadyExistsError(user.EmailAddress)
}
return fmt.Errorf("cannot insert user: %w", err)
}
if err := confirmationEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
if err := session.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
return nil
},
)
return user, session, err
}
func (s AuthService) OpenSessionWithoutPassword(ctx context.Context, userID gid.GID, organizationID gid.GID) (*coredata.Session, error) {
session := &coredata.Session{}
err := s.pg.WithTx(
ctx,
func(conn pg.Conn) (err error) {
session = coredata.NewRootSession(userID, s.sessionDuration)
err = session.Insert(ctx, conn)
if err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}
func (s AuthService) OpenSessionWithPassword(ctx context.Context, email mail.Addr, password string) (*coredata.User, *coredata.Session, error) {
v := validator.New()
v.Check(password, "password", PasswordValidator())
err := v.Error()
if err != nil {
return nil, nil, err
}
var (
user = &coredata.User{}
session = &coredata.Session{}
)
err = s.pg.WithTx(
ctx,
func(conn pg.Conn) error {
err := user.LoadByEmail(ctx, conn, email)
if err != nil {
// Do not leak information about non-existent users
if err != coredata.ErrResourceNotFound {
return fmt.Errorf("cannot load user by email: %w", err)
}
}
// Perform a password comparison even when the user does not exist to mitigate timing attacks
// and prevent revealing account existence.
if user.ID == gid.Nil {
s.hp.ComparePasswordAndHash([]byte(password+"qwertyuiop1234567890"), []byte("qwertyuiop1234567890"))
return NewInvalidCredentialsError("invalid email or password")
}
isPasswordMatch, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
if err != nil {
return fmt.Errorf("cannot verify password: %w", err)
}
if !isPasswordMatch {
return NewInvalidCredentialsError("invalid email or password")
}
session = coredata.NewRootSession(user.ID, s.sessionDuration)
err = session.Insert(ctx, conn)
if err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
return nil
},
)
return user, session, err
}

289
pkg/iam/errors.go Normal file
View File

@@ -0,0 +1,289 @@
// 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 iam
import (
"fmt"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
type ErrInvalidToken struct{ message string }
func NewInvalidTokenError() error {
return &ErrInvalidToken{"invalid invitation token"}
}
func (e ErrInvalidToken) Error() string {
return e.message
}
type ErrInvitationAlreadyAccepted struct{ InvitationID gid.GID }
func NewInvitationAlreadyAcceptedError(invitationID gid.GID) error {
return &ErrInvitationAlreadyAccepted{InvitationID: invitationID}
}
func (e ErrInvitationAlreadyAccepted) Error() string {
return fmt.Sprintf("invitation %q already accepted", e.InvitationID)
}
type ErrInvitationNotFound struct{ InvitationID gid.GID }
func NewInvitationNotFoundError(invitationID gid.GID) error {
return &ErrInvitationNotFound{InvitationID: invitationID}
}
func (e ErrInvitationNotFound) Error() string {
return fmt.Sprintf("invitation %q not found", e.InvitationID)
}
type ErrInvitationExpired struct{ InvitationID gid.GID }
func NewInvitationExpiredError(invitationID gid.GID) error {
return &ErrInvitationExpired{InvitationID: invitationID}
}
func (e ErrInvitationExpired) Error() string {
return fmt.Sprintf("invitation %q expired", e.InvitationID)
}
type ErrUserAlreadyExists struct{ EmailAddress mail.Addr }
func NewUserAlreadyExistsError(emailAddress mail.Addr) error {
return &ErrUserAlreadyExists{EmailAddress: emailAddress}
}
func (e ErrUserAlreadyExists) Error() string {
return fmt.Sprintf("user %q already exists", e.EmailAddress.String())
}
type ErrEmailAlreadyVerified struct{ message string }
func NewEmailAlreadyVerifiedError() error {
return &ErrEmailAlreadyVerified{"email already verified"}
}
func (e ErrEmailAlreadyVerified) Error() string {
return e.message
}
type ErrUserNotFound struct{ UserID gid.GID }
func NewUserNotFoundError(userID gid.GID) error {
return &ErrUserNotFound{userID}
}
func (e ErrUserNotFound) Error() string {
return fmt.Sprintf("user %q not found", e.UserID)
}
type ErrInvalidPassword struct{ message string }
func NewInvalidPasswordError(message string) error {
return &ErrInvalidPassword{message}
}
func (e ErrInvalidPassword) Error() string {
return e.message
}
type ErrEmailVerificationMismatch struct{ message string }
func NewEmailVerificationMismatchError() error {
return &ErrEmailVerificationMismatch{"email verification mismatch"}
}
func (e ErrEmailVerificationMismatch) Error() string {
return e.message
}
type ErrMembershipNotFound struct {
MembershipID gid.GID
}
func NewMembershipNotFoundError(membershipID gid.GID) error {
return &ErrMembershipNotFound{MembershipID: membershipID}
}
func (e ErrMembershipNotFound) Error() string {
return fmt.Sprintf("membership %q not found", e.MembershipID)
}
type ErrOrganizationNotFound struct{ OrganizationID gid.GID }
func NewOrganizationNotFoundError(organizationID gid.GID) error {
return &ErrOrganizationNotFound{OrganizationID: organizationID}
}
func (e ErrOrganizationNotFound) Error() string {
return fmt.Sprintf("organization %q not found", e.OrganizationID)
}
type ErrInsufficientPermissions struct {
IdentityID gid.GID
EntityID gid.GID
Action Action
}
func NewInsufficientPermissionsError(identityID gid.GID, entityID gid.GID, action Action) error {
return &ErrInsufficientPermissions{IdentityID: identityID, EntityID: entityID, Action: action}
}
func (e ErrInsufficientPermissions) Error() string {
return fmt.Sprintf("identity %q does not have sufficient permissions to perform action %s on entity %q", e.IdentityID, e.Action, e.EntityID)
}
type ErrSessionNotFound struct{ SessionID gid.GID }
func NewSessionNotFoundError(sessionID gid.GID) error {
return &ErrSessionNotFound{SessionID: sessionID}
}
func (e ErrSessionNotFound) Error() string {
return fmt.Sprintf("session %q not found", e.SessionID)
}
type ErrSessionExpired struct{ SessionID gid.GID }
func NewSessionExpiredError(sessionID gid.GID) error {
return &ErrSessionExpired{SessionID: sessionID}
}
func (e ErrSessionExpired) Error() string {
return fmt.Sprintf("session %q expired", e.SessionID)
}
type ErrMembershipAlreadyExists struct {
UserID gid.GID
OrganizationID gid.GID
}
func NewMembershipAlreadyExistsError(userID gid.GID, organizationID gid.GID) error {
return &ErrMembershipAlreadyExists{UserID: userID, OrganizationID: organizationID}
}
func (e ErrMembershipAlreadyExists) Error() string {
return fmt.Sprintf("membership already exists for user %q in organization %q", e.UserID, e.OrganizationID)
}
type ErrSAMLConfigurationNotFound struct{ ConfigID gid.GID }
func NewSAMLConfigurationNotFoundError(configID gid.GID) error {
return &ErrSAMLConfigurationNotFound{ConfigID: configID}
}
func (e ErrSAMLConfigurationNotFound) Error() string {
return fmt.Sprintf("SAML configuration %q not found", e.ConfigID)
}
type ErrUserAPIKeyNotFound struct{ UserAPIKeyID gid.GID }
func NewUserAPIKeyNotFoundError(userAPIKeyID gid.GID) error {
return &ErrUserAPIKeyNotFound{UserAPIKeyID: userAPIKeyID}
}
func (e ErrUserAPIKeyNotFound) Error() string {
return fmt.Sprintf("user API key %q not found", e.UserAPIKeyID)
}
type ErrUserAPIKeyExpired struct{ UserAPIKeyID gid.GID }
func NewUserAPIKeyExpiredError(userAPIKeyID gid.GID) error {
return &ErrUserAPIKeyExpired{UserAPIKeyID: userAPIKeyID}
}
func (e ErrUserAPIKeyExpired) Error() string {
return fmt.Sprintf("user API key %q expired", e.UserAPIKeyID)
}
type ErrSAMLConfigurationDomainNotVerified struct{ ConfigID gid.GID }
func NewSAMLConfigurationDomainNotVerifiedError(configID gid.GID) error {
return &ErrSAMLConfigurationDomainNotVerified{ConfigID: configID}
}
func (e ErrSAMLConfigurationDomainNotVerified) Error() string {
return fmt.Sprintf("SAML configuration %q domain not verified", e.ConfigID)
}
type ErrUnsupportedPrincipalType struct{ EntityType uint16 }
func NewUnsupportedPrincipalTypeError(entityType uint16) error {
return &ErrUnsupportedPrincipalType{EntityType: entityType}
}
func (e ErrUnsupportedPrincipalType) Error() string {
return fmt.Sprintf("unsupported principal type: %d", e.EntityType)
}
type ErrNoPermissionsDefined struct {
EntityModel string
Action Action
}
func NewNoPermissionsDefinedError(entityModel string, action Action) error {
return &ErrNoPermissionsDefined{EntityModel: entityModel, Action: action}
}
func (e ErrNoPermissionsDefined) Error() string {
return fmt.Sprintf("no permissions defined for action %s on entity %s", e.Action, e.EntityModel)
}
type ErrSignupDisabled struct{}
func NewErrSignupDisabled() error {
return &ErrSignupDisabled{}
}
func (e ErrSignupDisabled) Error() string {
return "signup is disabled"
}
type ErrInvalidCredentials struct{ message string }
func NewInvalidCredentialsError(message string) error {
return &ErrInvalidCredentials{message}
}
func (e ErrInvalidCredentials) Error() string {
return e.message
}
type ErrInvitationNotPending struct{ InvitationID gid.GID }
func NewInvitationNotPendingError(invitationID gid.GID) error {
return &ErrInvitationNotPending{InvitationID: invitationID}
}
func (e ErrInvitationNotPending) Error() string {
return fmt.Sprintf("invitation %q is not pending", e.InvitationID)
}
// TenantAccessError is used by API recovery middleware to translate authorization/tenant failures
// into a consistent client-facing error response.
//
// NOTE: This is intentionally generic to avoid leaking resource existence.
type TenantAccessError struct {
Message string
}
func (e *TenantAccessError) Error() string {
if e == nil || e.Message == "" {
return "tenant access denied"
}
return e.Message
}

File diff suppressed because it is too large Load Diff

826
pkg/iam/permissions.go Normal file
View File

@@ -0,0 +1,826 @@
// 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 iam
import (
"slices"
"go.probo.inc/probo/pkg/coredata"
)
type (
Role string
)
const (
RoleOwner Role = "OWNER"
RoleAdmin Role = "ADMIN"
RoleEmployee Role = "EMPLOYEE"
RoleViewer Role = "VIEWER"
RoleAuditor Role = "AUDITOR"
RoleFull Role = "FULL"
)
const (
ActionGet Action = "get"
ActionGetAssetType Action = "getAssetType"
ActionGetAssignedTo Action = "getAssignedTo"
ActionGetAuthMethod Action = "getAuthMethod"
ActionGetBusinessAssociateAgreement Action = "getBusinessAssociateAgreement"
ActionGetBusinessOwner Action = "getBusinessOwner"
ActionGetCustomDomain Action = "getCustomDomain"
ActionGetDataPrivacyAgreement Action = "getDataPrivacyAgreement"
ActionGetDataProtectionOfficer Action = "getDataProtectionOfficer"
ActionGetDataProtectionImpactAssessment Action = "getDataProtectionImpactAssessment"
ActionGetTransferImpactAssessment Action = "getTransferImpactAssessment"
ActionGetDocument Action = "getDocument"
ActionGetReport Action = "getReport"
ActionGetFile Action = "getFile"
ActionGetAudit Action = "getAudit"
ActionGetFileUrl Action = "getFileUrl"
ActionGetFramework Action = "getFramework"
ActionGetHorizontalLogoUrl Action = "getHorizontalLogoUrl"
ActionGetLogoUrl Action = "getLogoUrl"
ActionGetMeasure Action = "getMeasure"
ActionGetNdaFileUrl Action = "getNdaFileUrl"
ActionGetOrganization Action = "getOrganization"
ActionGetOwner Action = "getOwner"
ActionGetSecurityOwner Action = "getSecurityOwner"
ActionGetSigned Action = "getSigned"
ActionGetSignableDocument Action = "getSignableDocument"
ActionGetSnapshot Action = "getSnapshot"
ActionGetTask Action = "getTask"
ActionGetTrustCenter Action = "getTrustCenter"
ActionGetTrustCenterFile Action = "getTrustCenterFile"
ActionGetVendor Action = "getVendor"
ActionActiveCount Action = "activeCount"
ActionAudit Action = "audit"
ActionAvailableDocumentAccesses Action = "availableDocumentAccesses"
ActionDocumentVersion Action = "documentVersion"
ActionDownloadUrl Action = "downloadUrl"
ActionMemberships Action = "memberships"
ActionPendingRequestCount Action = "pendingRequestCount"
ActionPeoples Action = "peoples"
ActionReport Action = "report"
ActionReportUrl Action = "reportUrl"
ActionSignatures Action = "signatures"
ActionSignedBy Action = "signedBy"
ActionSpMetadataUrl Action = "spMetadataUrl"
ActionTestLoginUrl Action = "testLoginUrl"
ActionTotalCount Action = "totalCount"
ActionTrustCenterFile Action = "trustCenterFile"
ActionListAccesses Action = "listAccesses"
ActionListAssets Action = "listAssets"
ActionListAudits Action = "listAudits"
ActionListComplianceReports Action = "listComplianceReports"
ActionListContacts Action = "listContacts"
ActionListContinualImprovements Action = "listContinualImprovements"
ActionListRightsRequests Action = "listRightsRequests"
ActionListControls Action = "listControls"
ActionListData Action = "listData"
ActionListDocuments Action = "listDocuments"
ActionListEvidences Action = "listEvidences"
ActionListFrameworks Action = "listFrameworks"
ActionListInvitations Action = "listInvitations"
ActionListMeasures Action = "listMeasures"
ActionListMeetings Action = "listMeetings"
ActionListMembers Action = "listMembers"
ActionListNonconformities Action = "listNonconformities"
ActionListStatesOfApplicability Action = "listStatesOfApplicability"
ActionListObligations Action = "listObligations"
ActionListPeople Action = "listPeople"
ActionListProcessingActivities Action = "listProcessingActivities"
ActionListReferences Action = "listReferences"
ActionListRiskAssessments Action = "listRiskAssessments"
ActionListRisks Action = "listRisks"
ActionListSAMLConfigurations Action = "listSAMLConfigurations"
ActionListServices Action = "listServices"
ActionListSlackConnections Action = "listSlackConnections"
ActionListSnapshots Action = "listSnapshots"
ActionListTasks Action = "listTasks"
ActionListTrustCenterFiles Action = "listTrustCenterFiles"
ActionListVendors Action = "listVendors"
ActionListVersions Action = "listVersions"
ActionListSignableDocuments Action = "listSignableDocuments"
ActionListSignableDocumentVersion Action = "listSignableDocumentVersion"
ActionCreateAsset Action = "createAsset"
ActionCreateAudit Action = "createAudit"
ActionCreateContinualImprovement Action = "createContinualImprovement"
ActionCreateRightsRequest Action = "createRightsRequest"
ActionCreateControl Action = "createControl"
ActionCreateControlAuditMapping Action = "createControlAuditMapping"
ActionCreateControlObligationMapping Action = "createControlObligationMapping"
ActionCreateControlDocumentMapping Action = "createControlDocumentMapping"
ActionCreateControlMeasureMapping Action = "createControlMeasureMapping"
ActionCreateControlSnapshotMapping Action = "createControlSnapshotMapping"
ActionCreateStateOfApplicabilityControlMapping Action = "createStateOfApplicabilityControlMapping"
ActionCreateCustomDomain Action = "createCustomDomain"
ActionCreateDatum Action = "createDatum"
ActionCreateDocument Action = "createDocument"
ActionCreateDraftDocumentVersion Action = "createDraftDocumentVersion"
ActionCreateFramework Action = "createFramework"
ActionCreateMeasure Action = "createMeasure"
ActionCreateMeeting Action = "createMeeting"
ActionCreateNonconformity Action = "createNonconformity"
ActionCreateObligation Action = "createObligation"
ActionCreatePeople Action = "createPeople"
ActionCreateProcessingActivity Action = "createProcessingActivity"
ActionCreateStateOfApplicability Action = "createStateOfApplicability"
ActionCreateDataProtectionImpactAssessment Action = "createDataProtectionImpactAssessment"
ActionCreateTransferImpactAssessment Action = "createTransferImpactAssessment"
ActionCreateRisk Action = "createRisk"
ActionCreateRiskDocumentMapping Action = "createRiskDocumentMapping"
ActionCreateRiskMeasureMapping Action = "createRiskMeasureMapping"
ActionCreateRiskObligationMapping Action = "createRiskObligationMapping"
ActionCreateSAMLConfiguration Action = "createSAMLConfiguration"
ActionCreateSnapshot Action = "createSnapshot"
ActionCreateTask Action = "createTask"
ActionCreateTrustCenter Action = "createTrustCenter"
ActionCreateTrustCenterAccess Action = "createTrustCenterAccess"
ActionCreateTrustCenterFile Action = "createTrustCenterFile"
ActionCreateTrustCenterReference Action = "createTrustCenterReference"
ActionCreateVendor Action = "createVendor"
ActionCreateVendorContact Action = "createVendorContact"
ActionCreateVendorRiskAssessment Action = "createVendorRiskAssessment"
ActionCreateVendorService Action = "createVendorService"
ActionUpdateAsset Action = "updateAsset"
ActionUpdateAudit Action = "updateAudit"
ActionUpdateContinualImprovement Action = "updateContinualImprovement"
ActionUpdateRightsRequest Action = "updateRightsRequest"
ActionUpdateControl Action = "updateControl"
ActionUpdateDatum Action = "updateDatum"
ActionUpdateDocument Action = "updateDocument"
ActionUpdateDocumentVersion Action = "updateDocumentVersion"
ActionUpdateFramework Action = "updateFramework"
ActionUpdateMeasure Action = "updateMeasure"
ActionUpdateMeeting Action = "updateMeeting"
ActionUpdateMembership Action = "updateMembership"
ActionUpdateNonconformity Action = "updateNonconformity"
ActionUpdateObligation Action = "updateObligation"
ActionUpdateOrganization Action = "updateOrganization"
ActionUpdatePeople Action = "updatePeople"
ActionUpdateProcessingActivity Action = "updateProcessingActivity"
ActionUpdateStateOfApplicability Action = "updateStateOfApplicability"
ActionUpdateDataProtectionImpactAssessment Action = "updateDataProtectionImpactAssessment"
ActionUpdateTransferImpactAssessment Action = "updateTransferImpactAssessment"
ActionUpdateRisk Action = "updateRisk"
ActionUpdateSAMLConfiguration Action = "updateSAMLConfiguration"
ActionUpdateTask Action = "updateTask"
ActionUpdateTrustCenter Action = "updateTrustCenter"
ActionUpdateTrustCenterAccess Action = "updateTrustCenterAccess"
ActionUpdateTrustCenterFile Action = "updateTrustCenterFile"
ActionUpdateTrustCenterReference Action = "updateTrustCenterReference"
ActionUpdateVendor Action = "updateVendor"
ActionUpdateVendorBusinessAssociateAgreement Action = "updateVendorBusinessAssociateAgreement"
ActionUpdateVendorContact Action = "updateVendorContact"
ActionUpdateVendorDataPrivacyAgreement Action = "updateVendorDataPrivacyAgreement"
ActionUpdateVendorService Action = "updateVendorService"
ActionDeleteAsset Action = "deleteAsset"
ActionDeleteAudit Action = "deleteAudit"
ActionDeleteAuditReport Action = "deleteAuditReport"
ActionDeleteContinualImprovement Action = "deleteContinualImprovement"
ActionDeleteRightsRequest Action = "deleteRightsRequest"
ActionDeleteControl Action = "deleteControl"
ActionDeleteControlAuditMapping Action = "deleteControlAuditMapping"
ActionDeleteControlObligationMapping Action = "deleteControlObligationMapping"
ActionDeleteControlDocumentMapping Action = "deleteControlDocumentMapping"
ActionDeleteControlMeasureMapping Action = "deleteControlMeasureMapping"
ActionDeleteControlSnapshotMapping Action = "deleteControlSnapshotMapping"
ActionDeleteStateOfApplicabilityControlMapping Action = "deleteStateOfApplicabilityControlMapping"
ActionDeleteCustomDomain Action = "deleteCustomDomain"
ActionDeleteDatum Action = "deleteDatum"
ActionDeleteDocument Action = "deleteDocument"
ActionDeleteDraftDocumentVersion Action = "deleteDraftDocumentVersion"
ActionDeleteEvidence Action = "deleteEvidence"
ActionDeleteFramework Action = "deleteFramework"
ActionDeleteInvitation Action = "deleteInvitation"
ActionDeleteMeasure Action = "deleteMeasure"
ActionDeleteMeeting Action = "deleteMeeting"
ActionDeleteNonconformity Action = "deleteNonconformity"
ActionDeleteObligation Action = "deleteObligation"
ActionDeleteOrganization Action = "deleteOrganization"
ActionDeleteOrganizationHorizontalLogo Action = "deleteOrganizationHorizontalLogo"
ActionDeletePeople Action = "deletePeople"
ActionDeleteProcessingActivity Action = "deleteProcessingActivity"
ActionDeleteStateOfApplicability Action = "deleteStateOfApplicability"
ActionDeleteDataProtectionImpactAssessment Action = "deleteDataProtectionImpactAssessment"
ActionDeleteTransferImpactAssessment Action = "deleteTransferImpactAssessment"
ActionDeleteRisk Action = "deleteRisk"
ActionDeleteRiskDocumentMapping Action = "deleteRiskDocumentMapping"
ActionDeleteRiskMeasureMapping Action = "deleteRiskMeasureMapping"
ActionDeleteRiskObligationMapping Action = "deleteRiskObligationMapping"
ActionDeleteSAMLConfiguration Action = "deleteSAMLConfiguration"
ActionDeleteSnapshot Action = "deleteSnapshot"
ActionDeleteTask Action = "deleteTask"
ActionDeleteTrustCenterAccess Action = "deleteTrustCenterAccess"
ActionDeleteTrustCenterFile Action = "deleteTrustCenterFile"
ActionDeleteTrustCenterNDA Action = "deleteTrustCenterNDA"
ActionDeleteTrustCenterReference Action = "deleteTrustCenterReference"
ActionDeleteVendor Action = "deleteVendor"
ActionDeleteVendorBusinessAssociateAgreement Action = "deleteVendorBusinessAssociateAgreement"
ActionDeleteVendorComplianceReport Action = "deleteVendorComplianceReport"
ActionDeleteVendorContact Action = "deleteVendorContact"
ActionDeleteVendorDataPrivacyAgreement Action = "deleteVendorDataPrivacyAgreement"
ActionDeleteVendorService Action = "deleteVendorService"
ActionAcceptInvitation Action = "acceptInvitation"
ActionAssessVendor Action = "assessVendor"
ActionAssignTask Action = "assignTask"
ActionBulkDeleteDocuments Action = "bulkDeleteDocuments"
ActionBulkExportDocuments Action = "bulkExportDocuments"
ActionBulkPublishDocumentVersions Action = "bulkPublishDocumentVersions"
ActionBulkRequestSignatures Action = "bulkRequestSignatures"
ActionCancelSignatureRequest Action = "cancelSignatureRequest"
ActionSignDocument Action = "signDocument"
ActionConfirmEmail Action = "confirmEmail"
ActionDisableSAML Action = "disableSAML"
ActionEnableSAML Action = "enableSAML"
ActionExportDocumentVersionPDF Action = "exportDocumentVersionPDF"
ActionExportSignableVersionDocumentPDF Action = "exportSignableVersionDocumentPDF"
ActionExportProcessingActivitiesPDF Action = "exportProcessingActivitiesPDF"
ActionExportDataProtectionImpactAssessmentsPDF Action = "exportDataProtectionImpactAssessmentsPDF"
ActionExportTransferImpactAssessmentsPDF Action = "exportTransferImpactAssessmentsPDF"
ActionExportFramework Action = "exportFramework"
ActionGenerateDocumentChangelog Action = "generateDocumentChangelog"
ActionGenerateFrameworkStateOfApplicability Action = "generateFrameworkStateOfApplicability"
ActionImportFramework Action = "importFramework"
ActionImportMeasure Action = "importMeasure"
ActionInitiateDomainVerification Action = "initiateDomainVerification"
ActionInviteUser Action = "inviteUser"
ActionPublishDocumentVersion Action = "publishDocumentVersion"
ActionRemoveMember Action = "removeMember"
ActionRequestSignature Action = "requestSignature"
ActionSendSigningNotifications Action = "sendSigningNotifications"
ActionUnassignTask Action = "unassignTask"
ActionUploadAuditReport Action = "uploadAuditReport"
ActionUploadMeasureEvidence Action = "uploadMeasureEvidence"
ActionUploadTrustCenterNDA Action = "uploadTrustCenterNDA"
ActionUploadVendorBusinessAssociateAgreement Action = "uploadVendorBusinessAssociateAgreement"
ActionUploadVendorComplianceReport Action = "uploadVendorComplianceReport"
ActionUploadVendorDataPrivacyAgreement Action = "uploadVendorDataPrivacyAgreement"
ActionVerifyDomain Action = "verifyDomain"
)
var (
AllRoles = []Role{RoleOwner, RoleAdmin, RoleEmployee, RoleViewer, RoleAuditor, RoleFull}
EditRoles = []Role{RoleOwner, RoleAdmin, RoleFull}
CoreRoles = []Role{RoleOwner, RoleAdmin, RoleViewer, RoleFull}
NonEmployeeRoles = []Role{RoleOwner, RoleAdmin, RoleViewer, RoleAuditor, RoleFull}
InternalRoles = []Role{RoleOwner, RoleAdmin, RoleViewer, RoleEmployee, RoleFull}
)
var Permissions = map[uint16]map[Action][]Role{
coredata.OrganizationEntityType: {
ActionGet: AllRoles,
ActionGetLogoUrl: AllRoles,
ActionListSignableDocuments: InternalRoles,
ActionListDocuments: NonEmployeeRoles,
ActionGetHorizontalLogoUrl: NonEmployeeRoles,
ActionPeoples: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
ActionListFrameworks: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionListVendors: NonEmployeeRoles,
ActionListPeople: NonEmployeeRoles,
ActionListMeasures: NonEmployeeRoles,
ActionListRisks: NonEmployeeRoles,
ActionListAssets: NonEmployeeRoles,
ActionListData: NonEmployeeRoles,
ActionListAudits: NonEmployeeRoles,
ActionListNonconformities: NonEmployeeRoles,
ActionListObligations: NonEmployeeRoles,
ActionListContinualImprovements: NonEmployeeRoles,
ActionListRightsRequests: NonEmployeeRoles,
ActionListProcessingActivities: NonEmployeeRoles,
ActionExportProcessingActivitiesPDF: NonEmployeeRoles,
ActionExportDataProtectionImpactAssessmentsPDF: NonEmployeeRoles,
ActionExportTransferImpactAssessmentsPDF: NonEmployeeRoles,
ActionListSnapshots: NonEmployeeRoles,
ActionConfirmEmail: NonEmployeeRoles,
ActionAcceptInvitation: NonEmployeeRoles,
ActionListTrustCenterFiles: CoreRoles,
ActionGetTrustCenter: CoreRoles,
ActionMemberships: CoreRoles,
ActionListMembers: CoreRoles,
ActionListInvitations: CoreRoles,
ActionListSlackConnections: CoreRoles,
ActionGetCustomDomain: CoreRoles,
ActionListSAMLConfigurations: CoreRoles,
ActionListMeetings: CoreRoles,
ActionListStatesOfApplicability: NonEmployeeRoles,
ActionListTasks: CoreRoles,
ActionUpdateOrganization: EditRoles,
ActionDeleteOrganizationHorizontalLogo: EditRoles,
ActionCreateTrustCenter: EditRoles,
ActionInviteUser: EditRoles,
ActionUpdateMembership: EditRoles,
ActionCreatePeople: EditRoles,
ActionCreateVendor: EditRoles,
ActionCreateFramework: EditRoles,
ActionImportFramework: EditRoles,
ActionCreateControl: EditRoles,
ActionCreateMeasure: EditRoles,
ActionImportMeasure: EditRoles,
ActionCreateMeeting: EditRoles,
ActionCreateStateOfApplicability: EditRoles,
ActionCreateTask: EditRoles,
ActionCreateRisk: EditRoles,
ActionCreateDocument: EditRoles,
ActionCreateAsset: EditRoles,
ActionCreateDatum: EditRoles,
ActionCreateAudit: EditRoles,
ActionCreateNonconformity: EditRoles,
ActionCreateObligation: EditRoles,
ActionCreateContinualImprovement: EditRoles,
ActionCreateRightsRequest: EditRoles,
ActionCreateProcessingActivity: EditRoles,
ActionCreateSnapshot: EditRoles,
ActionCreateTrustCenterFile: EditRoles,
ActionSendSigningNotifications: EditRoles,
ActionRemoveMember: {RoleOwner, RoleFull},
ActionCreateCustomDomain: {RoleOwner},
ActionDeleteCustomDomain: {RoleOwner},
ActionInitiateDomainVerification: {RoleOwner},
ActionVerifyDomain: {RoleOwner},
ActionCreateSAMLConfiguration: {RoleOwner},
ActionDeleteOrganization: {RoleOwner},
},
coredata.TrustCenterEntityType: {
ActionGet: CoreRoles,
ActionGetNdaFileUrl: CoreRoles,
ActionGetOrganization: CoreRoles,
ActionListAccesses: CoreRoles,
ActionListReferences: CoreRoles,
ActionUpdateTrustCenter: EditRoles,
ActionUploadTrustCenterNDA: EditRoles,
ActionDeleteTrustCenterNDA: EditRoles,
ActionCreateTrustCenterAccess: EditRoles,
ActionCreateTrustCenterReference: EditRoles,
},
coredata.TrustCenterAccessEntityType: {
ActionGet: CoreRoles,
ActionActiveCount: CoreRoles,
ActionPendingRequestCount: CoreRoles,
ActionAvailableDocumentAccesses: CoreRoles,
ActionGetTrustCenterFile: CoreRoles,
ActionGetReport: CoreRoles,
ActionUpdateTrustCenterAccess: EditRoles,
ActionDeleteTrustCenterAccess: EditRoles,
},
coredata.TrustCenterReferenceEntityType: {
ActionGet: CoreRoles,
ActionGetLogoUrl: CoreRoles,
ActionUpdateTrustCenterReference: EditRoles,
ActionDeleteTrustCenterReference: EditRoles,
},
coredata.TrustCenterFileEntityType: {
ActionGet: CoreRoles,
ActionGetFileUrl: CoreRoles,
ActionUpdateTrustCenterFile: EditRoles,
ActionGetTrustCenterFile: EditRoles,
ActionDeleteTrustCenterFile: EditRoles,
},
coredata.UserEntityType: {
ActionGet: NonEmployeeRoles,
},
coredata.MembershipEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetAuthMethod: NonEmployeeRoles,
},
coredata.InvitationEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionDeleteInvitation: EditRoles,
},
coredata.PeopleEntityType: {
ActionGet: NonEmployeeRoles,
ActionUpdatePeople: EditRoles,
ActionDeletePeople: EditRoles,
},
coredata.VendorEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionListComplianceReports: NonEmployeeRoles,
ActionGetBusinessAssociateAgreement: NonEmployeeRoles,
ActionGetDataPrivacyAgreement: NonEmployeeRoles,
ActionListContacts: NonEmployeeRoles,
ActionListServices: NonEmployeeRoles,
ActionListRiskAssessments: NonEmployeeRoles,
ActionGetBusinessOwner: NonEmployeeRoles,
ActionGetSecurityOwner: NonEmployeeRoles,
ActionUpdateVendor: EditRoles,
ActionDeleteVendor: EditRoles,
ActionCreateVendorContact: EditRoles,
ActionCreateVendorService: EditRoles,
ActionUploadVendorComplianceReport: EditRoles,
ActionUploadVendorBusinessAssociateAgreement: EditRoles,
ActionDeleteVendorBusinessAssociateAgreement: EditRoles,
ActionUploadVendorDataPrivacyAgreement: EditRoles,
ActionCreateVendorRiskAssessment: EditRoles,
ActionAssessVendor: EditRoles,
},
coredata.VendorComplianceReportEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetVendor: NonEmployeeRoles,
ActionGetFile: NonEmployeeRoles,
ActionDeleteVendorComplianceReport: EditRoles,
},
coredata.VendorBusinessAssociateAgreementEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetVendor: NonEmployeeRoles,
ActionGetFileUrl: NonEmployeeRoles,
ActionUpdateVendorBusinessAssociateAgreement: EditRoles,
ActionDeleteVendorBusinessAssociateAgreement: EditRoles,
},
coredata.VendorContactEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetVendor: NonEmployeeRoles,
ActionUpdateVendorContact: EditRoles,
ActionDeleteVendorContact: EditRoles,
},
coredata.VendorServiceEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetVendor: NonEmployeeRoles,
ActionUpdateVendorService: EditRoles,
ActionDeleteVendorService: EditRoles,
},
coredata.VendorDataPrivacyAgreementEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetVendor: NonEmployeeRoles,
ActionGetFileUrl: NonEmployeeRoles,
ActionUpdateVendorDataPrivacyAgreement: EditRoles,
ActionDeleteVendorDataPrivacyAgreement: EditRoles,
},
coredata.VendorRiskAssessmentEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetVendor: NonEmployeeRoles,
},
coredata.FrameworkEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionGetLogoUrl: NonEmployeeRoles,
ActionCreateControl: EditRoles,
ActionUpdateFramework: EditRoles,
ActionDeleteFramework: EditRoles,
ActionGenerateFrameworkStateOfApplicability: EditRoles,
ActionExportFramework: EditRoles,
},
coredata.ControlEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetFramework: NonEmployeeRoles,
ActionListMeasures: NonEmployeeRoles,
ActionListDocuments: NonEmployeeRoles,
ActionListAudits: NonEmployeeRoles,
ActionListObligations: NonEmployeeRoles,
ActionListSnapshots: NonEmployeeRoles,
ActionListStatesOfApplicability: NonEmployeeRoles,
ActionUpdateControl: EditRoles,
ActionDeleteControl: EditRoles,
ActionCreateControlMeasureMapping: EditRoles,
ActionCreateControlDocumentMapping: EditRoles,
ActionDeleteControlMeasureMapping: EditRoles,
ActionDeleteControlDocumentMapping: EditRoles,
ActionCreateControlAuditMapping: EditRoles,
ActionDeleteControlAuditMapping: EditRoles,
ActionCreateControlObligationMapping: EditRoles,
ActionDeleteControlObligationMapping: EditRoles,
ActionCreateControlSnapshotMapping: EditRoles,
ActionCreateStateOfApplicabilityControlMapping: EditRoles,
ActionDeleteStateOfApplicabilityControlMapping: EditRoles,
ActionDeleteControlSnapshotMapping: EditRoles,
},
coredata.StateOfApplicabilityControlEntityType: {
ActionDeleteStateOfApplicabilityControlMapping: EditRoles,
},
coredata.MeasureEntityType: {
ActionListTasks: CoreRoles,
ActionGet: NonEmployeeRoles,
ActionListEvidences: NonEmployeeRoles,
ActionListRisks: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
ActionUpdateMeasure: EditRoles,
ActionDeleteMeasure: EditRoles,
ActionUploadMeasureEvidence: EditRoles,
},
coredata.TaskEntityType: {
ActionGet: CoreRoles,
ActionGetAssignedTo: CoreRoles,
ActionGetOrganization: CoreRoles,
ActionGetMeasure: CoreRoles,
ActionListEvidences: CoreRoles,
ActionUpdateTask: EditRoles,
ActionDeleteTask: EditRoles,
ActionAssignTask: EditRoles,
ActionUnassignTask: EditRoles,
},
coredata.EvidenceEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetFile: NonEmployeeRoles,
ActionGetTask: CoreRoles,
ActionGetMeasure: NonEmployeeRoles,
ActionDeleteEvidence: EditRoles,
},
coredata.DocumentEntityType: {
ActionListSignableDocumentVersion: AllRoles,
ActionGetSigned: AllRoles,
ActionGetSignableDocument: InternalRoles,
ActionGet: NonEmployeeRoles,
ActionGetOwner: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionBulkExportDocuments: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionListVersions: NonEmployeeRoles,
ActionUpdateDocument: EditRoles,
ActionDeleteDocument: EditRoles,
ActionPublishDocumentVersion: EditRoles,
ActionBulkPublishDocumentVersions: EditRoles,
ActionBulkDeleteDocuments: EditRoles,
ActionGenerateDocumentChangelog: EditRoles,
ActionCreateDraftDocumentVersion: EditRoles,
ActionDeleteDraftDocumentVersion: EditRoles,
ActionUpdateDocumentVersion: EditRoles,
ActionRequestSignature: EditRoles,
ActionBulkRequestSignatures: EditRoles,
ActionSendSigningNotifications: EditRoles,
},
coredata.DocumentVersionEntityType: {
ActionSignDocument: AllRoles,
ActionExportSignableVersionDocumentPDF: AllRoles,
ActionGetSigned: AllRoles,
ActionGet: NonEmployeeRoles,
ActionGetFile: NonEmployeeRoles,
ActionGetOwner: NonEmployeeRoles,
ActionGetDocument: NonEmployeeRoles,
ActionSignatures: NonEmployeeRoles,
ActionExportDocumentVersionPDF: NonEmployeeRoles,
ActionUpdateDocumentVersion: EditRoles,
ActionRequestSignature: EditRoles,
ActionDeleteDraftDocumentVersion: EditRoles,
},
coredata.DocumentVersionSignatureEntityType: {
ActionGet: NonEmployeeRoles,
ActionDocumentVersion: NonEmployeeRoles,
ActionSignedBy: NonEmployeeRoles,
ActionCancelSignatureRequest: EditRoles,
},
coredata.RiskEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOwner: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionListMeasures: NonEmployeeRoles,
ActionListDocuments: NonEmployeeRoles,
ActionListObligations: NonEmployeeRoles,
ActionUpdateRisk: EditRoles,
ActionDeleteRisk: EditRoles,
ActionCreateRiskMeasureMapping: EditRoles,
ActionDeleteRiskMeasureMapping: EditRoles,
ActionCreateRiskDocumentMapping: EditRoles,
ActionDeleteRiskDocumentMapping: EditRoles,
ActionCreateRiskObligationMapping: EditRoles,
ActionDeleteRiskObligationMapping: EditRoles,
},
coredata.AssetEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOwner: NonEmployeeRoles,
ActionListVendors: NonEmployeeRoles,
ActionGetAssetType: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionUpdateAsset: EditRoles,
ActionDeleteAsset: EditRoles,
},
coredata.DatumEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOwner: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionListVendors: NonEmployeeRoles,
ActionUpdateDatum: EditRoles,
ActionDeleteDatum: EditRoles,
},
coredata.AuditEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetFile: NonEmployeeRoles,
ActionGetFramework: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionReport: NonEmployeeRoles,
ActionReportUrl: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionUpdateAudit: EditRoles,
ActionDeleteAudit: EditRoles,
ActionUploadAuditReport: EditRoles,
ActionDeleteAuditReport: EditRoles,
},
coredata.ReportEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetAudit: NonEmployeeRoles,
ActionGetFile: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionGetSnapshot: NonEmployeeRoles,
ActionDownloadUrl: NonEmployeeRoles,
},
coredata.NonconformityEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOwner: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionAudit: NonEmployeeRoles,
ActionUpdateNonconformity: EditRoles,
ActionDeleteNonconformity: EditRoles,
},
coredata.ObligationEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionGetOwner: NonEmployeeRoles,
ActionListRisks: NonEmployeeRoles,
ActionUpdateObligation: EditRoles,
ActionDeleteObligation: EditRoles,
},
coredata.ContinualImprovementEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOwner: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionUpdateContinualImprovement: EditRoles,
ActionDeleteContinualImprovement: EditRoles,
},
coredata.RightsRequestEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionUpdateRightsRequest: EditRoles,
ActionDeleteRightsRequest: EditRoles,
},
coredata.ProcessingActivityEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionListVendors: NonEmployeeRoles,
ActionGetDataProtectionOfficer: NonEmployeeRoles,
ActionGetDataProtectionImpactAssessment: NonEmployeeRoles,
ActionGetTransferImpactAssessment: NonEmployeeRoles,
ActionExportProcessingActivitiesPDF: NonEmployeeRoles,
ActionUpdateProcessingActivity: EditRoles,
ActionDeleteProcessingActivity: EditRoles,
ActionCreateDataProtectionImpactAssessment: EditRoles,
ActionCreateTransferImpactAssessment: EditRoles,
},
coredata.DataProtectionImpactAssessmentEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionCreateDataProtectionImpactAssessment: EditRoles,
ActionUpdateDataProtectionImpactAssessment: EditRoles,
ActionDeleteDataProtectionImpactAssessment: EditRoles,
},
coredata.TransferImpactAssessmentEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionCreateTransferImpactAssessment: EditRoles,
ActionUpdateTransferImpactAssessment: EditRoles,
ActionDeleteTransferImpactAssessment: EditRoles,
},
coredata.SnapshotEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionDeleteSnapshot: EditRoles,
},
coredata.CustomDomainEntityType: {
ActionGet: {RoleOwner, RoleAdmin},
ActionDeleteCustomDomain: {RoleOwner},
},
coredata.SAMLConfigurationEntityType: {
ActionGet: {RoleOwner, RoleAdmin},
ActionSpMetadataUrl: {RoleOwner, RoleAdmin},
ActionTestLoginUrl: {RoleOwner, RoleAdmin},
ActionUpdateSAMLConfiguration: {RoleOwner, RoleAdmin},
ActionDeleteSAMLConfiguration: {RoleOwner, RoleAdmin},
ActionEnableSAML: {RoleOwner, RoleAdmin},
ActionDisableSAML: {RoleOwner, RoleAdmin},
ActionVerifyDomain: {RoleOwner},
},
coredata.FileEntityType: {
ActionGet: NonEmployeeRoles,
ActionDownloadUrl: NonEmployeeRoles,
},
coredata.TrustCenterDocumentAccessEntityType: {
ActionGet: CoreRoles,
ActionReport: CoreRoles,
ActionTrustCenterFile: CoreRoles,
},
coredata.MeetingEntityType: {
ActionGet: CoreRoles,
ActionGetOrganization: CoreRoles,
ActionTotalCount: CoreRoles,
ActionUpdateMeeting: EditRoles,
ActionDeleteMeeting: EditRoles,
},
coredata.StateOfApplicabilityEntityType: {
ActionGet: NonEmployeeRoles,
ActionGetOrganization: NonEmployeeRoles,
ActionListControls: NonEmployeeRoles,
ActionTotalCount: NonEmployeeRoles,
ActionUpdateStateOfApplicability: EditRoles,
ActionDeleteStateOfApplicability: EditRoles,
ActionDeleteStateOfApplicabilityControlMapping: EditRoles,
},
}
func GetPermissionsForAction(entityType uint16, action Action) []Role {
if entityActions, ok := Permissions[entityType]; ok {
if roles, ok := entityActions[action]; ok {
return roles
}
}
return nil
}
func GetPermissionsByRole(userRole Role) map[string]map[Action]bool {
permissions := make(map[string]map[Action]bool)
for entityType, actions := range Permissions {
entityTypeName, ok := coredata.EntityModel(entityType)
if !ok {
continue
}
if permissions[entityTypeName] == nil {
permissions[entityTypeName] = make(map[Action]bool)
}
for action, allowedRoles := range actions {
if slices.Contains(allowedRoles, userRole) {
permissions[entityTypeName][action] = true
}
}
}
return permissions
}

168
pkg/iam/saml/attributes.go Normal file
View File

@@ -0,0 +1,168 @@
// 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 saml
import (
"fmt"
"strings"
"github.com/crewjam/saml"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/mail"
)
func extractUserAttributes(assertion *saml.Assertion, config *coredata.SAMLConfiguration) (mail.Addr, string, *coredata.MembershipRole, error) {
var (
email mail.Addr
fullname string
role *coredata.MembershipRole
)
if len(assertion.AttributeStatements) == 0 {
if assertion.Subject != nil && assertion.Subject.NameID != nil {
email, err := mail.ParseAddr(assertion.Subject.NameID.Value)
if err != nil {
return mail.Nil, "", nil, fmt.Errorf("cannot parse email: %w", err)
}
fullname = email.String()
role = nil
return email, fullname, role, nil
}
return mail.Nil, "", nil, fmt.Errorf("no attribute statement and no NameID in assertion")
}
emailString, err := extractAttributeValue(assertion, config.AttributeEmail)
if err != nil {
if assertion.Subject != nil && assertion.Subject.NameID != nil {
emailString = assertion.Subject.NameID.Value
} else {
return mail.Nil, "", nil, fmt.Errorf("cannot extract email: %w", err)
}
}
email, err = mail.ParseAddr(emailString)
if err != nil {
return mail.Nil, "", nil, fmt.Errorf("cannot parse email: %w", err)
}
firstname, err := extractAttributeValue(assertion, config.AttributeFirstname)
if err != nil {
firstname = ""
}
lastname, err := extractAttributeValue(assertion, config.AttributeLastname)
if err != nil {
lastname = ""
}
if firstname != "" && lastname != "" {
fullname = strings.TrimSpace(firstname + " " + lastname)
} else if firstname != "" {
fullname = firstname
} else if lastname != "" {
fullname = lastname
} else {
fullname = email.String()
}
roleString, err := extractAttributeValue(assertion, config.AttributeRole)
if err != nil {
role = nil
}
if roleString != "" {
role = mapSAMLRoleToSystemRole(roleString)
}
return email, fullname, role, nil
}
func extractAttributeValue(assertion *saml.Assertion, attributeName string) (string, error) {
if len(assertion.AttributeStatements) == 0 {
return "", fmt.Errorf("no attribute statement in assertion")
}
for _, statement := range assertion.AttributeStatements {
for _, attr := range statement.Attributes {
if attr.Name == attributeName {
if len(attr.Values) == 0 {
return "", fmt.Errorf("attribute %q has no values", attributeName)
}
return attr.Values[0].Value, nil
}
}
}
return "", fmt.Errorf("attribute %q not found in assertion", attributeName)
}
func extractEmailFromAssertion(assertion *saml.Assertion) (string, error) {
commonEmailAttributes := []string{
"email",
"Email",
"emailAddress",
"mail",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
"http://schemas.xmlsoap.org/claims/EmailAddress",
}
for _, attrName := range commonEmailAttributes {
email, err := extractAttributeValue(assertion, attrName)
if err == nil && email != "" {
return email, nil
}
}
if assertion.Subject != nil && assertion.Subject.NameID != nil && assertion.Subject.NameID.Value != "" {
return assertion.Subject.NameID.Value, nil
}
return "", fmt.Errorf("could not extract email from assertion")
}
func extractEmailDomain(email string) (string, error) {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return "", fmt.Errorf("invalid email address: %s", email)
}
domain := strings.ToLower(strings.TrimSpace(parts[1]))
if domain == "" {
return "", fmt.Errorf("empty domain in email address: %s", email)
}
return domain, nil
}
func mapSAMLRoleToSystemRole(samlRole string) *coredata.MembershipRole {
if samlRole != "" && isValidRole(samlRole) {
role := coredata.MembershipRole(samlRole)
return &role
}
return nil
}
func isValidRole(role string) bool {
switch role {
case "OWNER", "ADMIN", "EMPLOYEE", "VIEWER":
return true
default:
return false
}
}

90
pkg/iam/saml/errors.go Normal file
View File

@@ -0,0 +1,90 @@
// 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 saml
import (
"fmt"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
)
type ErrSAMLConfigurationNotFound struct{ ConfigID gid.GID }
func NewSAMLConfigurationNotFoundError(configID gid.GID) error {
return &ErrSAMLConfigurationNotFound{ConfigID: configID}
}
func (e ErrSAMLConfigurationNotFound) Error() string {
return fmt.Sprintf("SAML configuration %q not found", e.ConfigID)
}
type ErrSAMLDisabled struct{}
func NewSAMLDisabledError() error {
return &ErrSAMLDisabled{}
}
func (e ErrSAMLDisabled) Error() string {
return "SAML is disabled for this organization"
}
type ErrInvalidAssertion struct {
AssertionID string
Err error
}
func NewInvalidAssertionError(assertionID string, err error) error {
return &ErrInvalidAssertion{AssertionID: assertionID, Err: err}
}
func (e ErrInvalidAssertion) Error() string {
return fmt.Sprintf("invalid assertion %q: %v", e.AssertionID, e.Err)
}
type ErrReplayAttackDetected struct {
AssertionID string
}
func NewReplayAttackDetectedError(assertionID string) error {
return &ErrReplayAttackDetected{AssertionID: assertionID}
}
func (e ErrReplayAttackDetected) Error() string {
return fmt.Sprintf("replay attack detected for assertion %q", e.AssertionID)
}
type ErrEmailDomainMismatch struct {
Email mail.Addr
ExpectedDomain string
}
func NewEmailDomainMismatchError(email mail.Addr, expectedDomain string) error {
return &ErrEmailDomainMismatch{Email: email, ExpectedDomain: expectedDomain}
}
func (e ErrEmailDomainMismatch) Error() string {
return fmt.Sprintf("email domain mismatch: assertion contains email %q but SAML config is for domain %q", e.Email, e.ExpectedDomain)
}
type ErrSAMLAutoSignupDisabled struct{ ConfigID gid.GID }
func NewSAMLAutoSignupDisabledError(configID gid.GID) error {
return &ErrSAMLAutoSignupDisabled{ConfigID: configID}
}
func (e ErrSAMLAutoSignupDisabled) Error() string {
return fmt.Sprintf("SAML auto-signup is disabled for configuration %q", e.ConfigID)
}

97
pkg/iam/saml/gc.go Normal file
View File

@@ -0,0 +1,97 @@
// 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 saml
import (
"context"
"fmt"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
)
const (
DefaultGarbageCollectionInterval = 1 * time.Hour
)
type (
GarbageCollector struct {
pg *pg.Client
interval time.Duration
logger *log.Logger
}
)
func NewGarbageCollector(
pg *pg.Client,
interval time.Duration,
logger *log.Logger,
) *GarbageCollector {
return &GarbageCollector{
pg: pg,
interval: interval,
logger: logger.Named("saml.garbage_collector").With(log.Duration("interval", interval)),
}
}
func (gc *GarbageCollector) Run(ctx context.Context) error {
gc.logger.InfoCtx(ctx, "saml garbage collector starting")
if err := gc.cleanup(ctx); err != nil {
gc.logger.ErrorCtx(ctx, "cannot run initial cleanup", log.Error(err))
}
for {
select {
case <-ctx.Done():
gc.logger.InfoCtx(ctx, "saml garbage collector shutting down")
return ctx.Err()
case <-time.After(gc.interval):
if err := gc.cleanup(ctx); err != nil {
gc.logger.ErrorCtx(ctx, "cannot run periodic cleanup", log.Error(err))
}
}
}
}
func (gc *GarbageCollector) cleanup(ctx context.Context) error {
now := time.Now()
return gc.pg.WithTx(
ctx,
func(tx pg.Conn) error {
assertionsDeleted, err := coredata.DeleteExpiredSAMLAssertions(ctx, tx, now)
if err != nil {
return fmt.Errorf("cannot delete expired saml assertions: %w", err)
}
requestsDeleted, err := coredata.DeleteExpiredSAMLRequests(ctx, tx, now)
if err != nil {
return fmt.Errorf("cannot delete expired saml requests: %w", err)
}
gc.logger.InfoCtx(
ctx,
"saml garbage collector cleaned up expired assertions and requests",
log.Int64("assertions", assertionsDeleted),
log.Int64("requests", requestsDeleted),
)
return nil
},
)
}

View File

@@ -0,0 +1,87 @@
// 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 saml
import (
"crypto/x509"
"encoding/base64"
"encoding/xml"
"fmt"
"github.com/crewjam/saml"
)
func ParseIdpMetadata(metadataXML []byte) (string, string, *x509.Certificate, error) {
var entityDescriptor saml.EntityDescriptor
err := xml.Unmarshal(metadataXML, &entityDescriptor)
if err != nil {
return "", "", nil, fmt.Errorf("cannot parse metadata XML: %w", err)
}
if len(entityDescriptor.IDPSSODescriptors) == 0 {
return "", "", nil, fmt.Errorf("no IDPSSODescriptor found in metadata")
}
idpDescriptor := entityDescriptor.IDPSSODescriptors[0]
ssoURL, err := getSsoURLFromMetadata(idpDescriptor)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get SSO URL from metadata: %w", err)
}
cert, err := getCertificateFromMetadata(idpDescriptor)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get certificate from metadata: %w", err)
}
return entityDescriptor.EntityID, ssoURL, cert, nil
}
func getSsoURLFromMetadata(idpDescriptor saml.IDPSSODescriptor) (string, error) {
for _, sso := range idpDescriptor.SingleSignOnServices {
if sso.Binding == saml.HTTPPostBinding || sso.Binding == saml.HTTPRedirectBinding {
return sso.Location, nil
}
}
if len(idpDescriptor.SingleSignOnServices) > 0 {
return idpDescriptor.SingleSignOnServices[0].Location, nil
}
return "", fmt.Errorf("no SingleSignOnService found in metadata")
}
func getCertificateFromMetadata(idpDescriptor saml.IDPSSODescriptor) (*x509.Certificate, error) {
for _, keyDescriptor := range idpDescriptor.KeyDescriptors {
if keyDescriptor.Use == "signing" || keyDescriptor.Use == "" {
if len(keyDescriptor.KeyInfo.X509Data.X509Certificates) > 0 {
certData := keyDescriptor.KeyInfo.X509Data.X509Certificates[0].Data
certDER, err := base64.StdEncoding.DecodeString(certData)
if err != nil {
return nil, fmt.Errorf("cannot decode certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("cannot parse certificate: %w", err)
}
return cert, nil
}
}
}
return nil, fmt.Errorf("no signing certificate found in metadata")
}

402
pkg/iam/saml/service.go Normal file
View File

@@ -0,0 +1,402 @@
// 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 saml
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/crewjam/saml"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/gid"
)
type (
Service struct {
pg *pg.Client
encryptionKey cipher.EncryptionKey
baseURL string
certificate *x509.Certificate
privateKey *rsa.PrivateKey
logger *log.Logger
}
UserInfo struct {
Email string
FullName string
Role *coredata.MembershipRole
SAMLSubject string
OrganizationID gid.GID
SAMLConfigID gid.GID
}
)
func NewService(
pg *pg.Client,
encryptionKey cipher.EncryptionKey,
baseURL string,
certificate *x509.Certificate,
privateKey *rsa.PrivateKey,
logger *log.Logger,
) (*Service, error) {
return &Service{
pg: pg,
encryptionKey: encryptionKey,
baseURL: baseURL,
certificate: certificate,
privateKey: privateKey,
logger: logger,
}, nil
}
func (s *Service) Run(ctx context.Context) error {
gc := NewGarbageCollector(s.pg, DefaultGarbageCollectionInterval, s.logger)
gcCtx, stopGC := context.WithCancel(ctx)
defer stopGC()
errCh := make(chan error, 1)
go func() {
errCh <- gc.Run(gcCtx)
}()
select {
case <-ctx.Done():
stopGC()
<-errCh
return ctx.Err()
case err := <-errCh:
if err != nil {
s.logger.ErrorCtx(ctx, "saml garbage collector failed", log.Error(err))
return err
}
return nil
}
}
func (s *Service) GenerateSpMetadata() ([]byte, error) {
sp := s.baseServiceProvider()
return xml.MarshalIndent(sp.Metadata(), "", " ")
}
func (s *Service) InitiateLogin(
ctx context.Context,
configID gid.GID,
) (*url.URL, error) {
var (
now = time.Now()
requestExpiry = now.Add(10 * time.Minute)
redirect *url.URL
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
config := &coredata.SAMLConfiguration{}
err := config.LoadByID(ctx, tx, coredata.NewNoScope(), configID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSAMLConfigurationNotFoundError(configID)
}
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
if config.EnforcementPolicy == coredata.SAMLEnforcementPolicyOff {
return NewSAMLDisabledError()
}
sp, err := s.serviceProvider(ctx, config)
if err != nil {
return fmt.Errorf("cannot build service provider: %w", err)
}
req, err := sp.MakeAuthenticationRequest(config.IdPSsoURL, saml.HTTPRedirectBinding, saml.HTTPPostBinding)
if err != nil {
return fmt.Errorf("cannot create authentication request: %w", err)
}
samlRequest := coredata.SAMLRequest{
ID: req.ID,
OrganizationID: config.OrganizationID,
CreatedAt: now,
ExpiresAt: requestExpiry,
}
if err := samlRequest.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert SAML request: %w", err)
}
redirect, err = req.Redirect(config.ID.String(), sp)
if err != nil {
return fmt.Errorf("cannot generate redirect URL: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return redirect, nil
}
func (s *Service) HandleAssertion(
ctx context.Context,
samlResponse string,
configID gid.GID,
) (*coredata.User, *coredata.Membership, error) {
var (
now = time.Now()
user = &coredata.User{}
membership = &coredata.Membership{}
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
config := &coredata.SAMLConfiguration{}
err := config.LoadByID(ctx, tx, coredata.NewNoScope(), configID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSAMLConfigurationNotFoundError(configID)
}
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
if config.EnforcementPolicy == coredata.SAMLEnforcementPolicyOff {
return NewSAMLDisabledError()
}
sp, err := s.serviceProvider(ctx, config)
if err != nil {
return fmt.Errorf("cannot create service provider: %w", err)
}
possibleRequestIDs, err := coredata.LoadValidRequestIDsForOrganization(ctx, tx, config.OrganizationID, now)
if err != nil {
return fmt.Errorf("cannot load valid request IDs: %w", err)
}
decodedResponse, err := base64.StdEncoding.DecodeString(samlResponse)
if err != nil {
return fmt.Errorf("cannot decode SAML response: %w", err)
}
assertion, err := sp.ParseXMLResponse(decodedResponse, possibleRequestIDs, sp.AcsURL)
if err != nil {
return fmt.Errorf("cannot parse SAML response: %w", err)
}
err = s.validateAssertion(assertion, config, now)
if err != nil {
return NewInvalidAssertionError(assertion.ID, err)
}
expiresAt := now.Add(24 * time.Hour)
if assertion.Conditions.NotOnOrAfter.IsZero() {
expiresAt = assertion.Conditions.NotOnOrAfter
}
samlAssertion := coredata.SAMLAssertion{
ID: assertion.ID,
OrganizationID: config.OrganizationID,
UsedAt: now,
ExpiresAt: expiresAt,
}
err = samlAssertion.Insert(ctx, tx)
if err != nil {
if err == coredata.ErrResourceAlreadyExists {
return NewReplayAttackDetectedError(samlAssertion.ID)
}
return fmt.Errorf("cannot insert SAML assertion: %w", err)
}
email, fullname, role, err := extractUserAttributes(assertion, config)
if err != nil {
return fmt.Errorf("cannot extract user attributes: %w", err)
}
if !strings.EqualFold(email.Domain(), config.EmailDomain) {
return NewEmailDomainMismatchError(email, config.EmailDomain)
}
err = user.LoadByEmail(ctx, tx, email)
if err == coredata.ErrResourceNotFound && !config.AutoSignupEnabled {
return NewSAMLAutoSignupDisabledError(config.ID)
} else if err == coredata.ErrResourceNotFound && config.AutoSignupEnabled {
*user = coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: email,
HashedPassword: nil,
EmailAddressVerified: true,
FullName: fullname,
CreatedAt: now,
UpdatedAt: now,
}
err := user.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert user: %w", err)
}
} else if err != nil {
return fmt.Errorf("cannot load user: %w", err)
} else {
user.SAMLSubject = &assertion.Subject.NameID.Value
user.FullName = fullname
user.EmailAddress = email
user.EmailAddressVerified = true
user.UpdatedAt = now
err = user.Update(ctx, tx)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
}
err = membership.LoadByUserAndOrg(ctx, tx, coredata.NewNoScope(), user.ID, config.OrganizationID)
if err != nil && err != coredata.ErrResourceNotFound {
return fmt.Errorf("cannot load membership: %w", err)
}
isMember := membership.ID != gid.Nil
if !isMember {
membership = &coredata.Membership{
ID: gid.New(config.ID.TenantID(), coredata.MembershipEntityType),
UserID: user.ID,
OrganizationID: config.OrganizationID,
Role: coredata.MembershipRoleViewer,
CreatedAt: now,
UpdatedAt: now,
}
err = membership.Insert(ctx, tx, coredata.NewNoScope())
if err != nil {
return fmt.Errorf("cannot insert membership: %w", err)
}
}
if role != nil {
membership.Role = *role
membership.UpdatedAt = now
err = membership.Update(ctx, tx, coredata.NewNoScope())
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
}
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return user, membership, nil
}
func (s *Service) validateAssertion(assertion *saml.Assertion, config *coredata.SAMLConfiguration, now time.Time) error {
const clockSkewTolerance = 5 * time.Minute
if assertion.ID == "" {
return errors.New("assertion ID is required")
}
if assertion.Subject == nil || assertion.Subject.NameID == nil {
return fmt.Errorf("subject or NameID missing")
}
if assertion.Issuer.Value != config.IdPEntityID {
return fmt.Errorf("assertion issuer %q does not match expected issuer %q",
assertion.Issuer.Value, config.IdPEntityID)
}
if assertion.Conditions == nil {
return errors.New("assertion conditions are required")
}
if assertion.Conditions.NotOnOrAfter.IsZero() {
return errors.New("assertion NotOnOrAfter condition is required")
}
if !assertion.Conditions.NotBefore.IsZero() {
if now.Add(clockSkewTolerance).Before(assertion.Conditions.NotBefore) {
return fmt.Errorf("assertion not yet valid (NotBefore: %v, now: %v)",
assertion.Conditions.NotBefore, now)
}
}
if now.Add(-clockSkewTolerance).After(assertion.Conditions.NotOnOrAfter) {
return fmt.Errorf("assertion expired (NotOnOrAfter: %v, now: %v)",
assertion.Conditions.NotOnOrAfter, now)
}
if len(assertion.Conditions.AudienceRestrictions) == 0 {
return errors.New("assertion audience restriction is required")
}
expectedAudience := baseurl.MustParse(s.baseURL).WithPath("/api/connect/v1/saml/2.0/metadata").MustString()
audienceValid := false
for _, restriction := range assertion.Conditions.AudienceRestrictions {
if restriction.Audience.Value == expectedAudience {
audienceValid = true
break
}
}
if !audienceValid {
return fmt.Errorf("assertion audience %q does not match expected %q",
assertion.Conditions.AudienceRestrictions, expectedAudience)
}
return nil
}
func (s *Service) baseServiceProvider() *saml.ServiceProvider {
baseURL := baseurl.MustParse(s.baseURL)
metadataURL := baseURL.WithPath("/api/connect/v1/saml/2.0/metadata").URL()
acsURL := baseURL.WithPath("/api/connect/v1/saml/2.0/consume").URL()
return &saml.ServiceProvider{
EntityID: metadataURL.String(),
Key: s.privateKey,
Certificate: s.certificate,
MetadataURL: metadataURL,
AcsURL: acsURL,
SloURL: acsURL,
AllowIDPInitiated: true,
}
}

68
pkg/iam/saml/sp.go Normal file
View File

@@ -0,0 +1,68 @@
// 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 saml
import (
"context"
"encoding/base64"
"fmt"
"github.com/crewjam/saml"
"go.probo.inc/probo/pkg/coredata"
)
func (s *Service) serviceProvider(
ctx context.Context,
config *coredata.SAMLConfiguration,
) (*saml.ServiceProvider, error) {
cert, err := config.GetIdPCertificate()
if err != nil {
return nil, fmt.Errorf("cannot parse IdP certificate: %w", err)
}
sp := s.baseServiceProvider()
sp.IDPMetadata = &saml.EntityDescriptor{
EntityID: config.IdPEntityID,
IDPSSODescriptors: []saml.IDPSSODescriptor{
{
SSODescriptor: saml.SSODescriptor{
RoleDescriptor: saml.RoleDescriptor{
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
KeyDescriptors: []saml.KeyDescriptor{
{
Use: "signing",
KeyInfo: saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: base64.StdEncoding.EncodeToString(cert.Raw)},
},
},
},
},
},
},
},
SingleSignOnServices: []saml.Endpoint{
{
Binding: saml.HTTPRedirectBinding,
Location: config.IdPSsoURL,
},
},
},
},
}
return sp, nil
}

View File

@@ -0,0 +1,97 @@
// 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 iam
import (
"crypto/x509"
"encoding/pem"
"fmt"
"net/url"
pemutil "go.probo.inc/probo/pkg/crypto/pem"
)
// ValidateIdPConfiguration validates only the IdP (Identity Provider) configuration.
// This validates user-provided data from the IdP.
// SP (Service Provider) configuration is generated by the application and doesn't need validation.
func ValidateIdPConfiguration(
idpEntityID string,
idpSsoURL string,
idpCertificate string,
) error {
// Validate IdP Entity ID
if idpEntityID == "" {
return fmt.Errorf("IdP Entity ID cannot be empty")
}
// Validate IdP SSO URL - accept both HTTP and HTTPS
if err := validateURL(idpSsoURL); err != nil {
return err
}
// Validate IdP certificate
if err := validateCertificate(idpCertificate); err != nil {
return err
}
return nil
}
func validateURL(urlStr string) error {
if urlStr == "" {
return fmt.Errorf("URL cannot be empty")
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
if parsedURL.Scheme == "" {
return fmt.Errorf("URL must have a scheme (http or https)")
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("URL scheme must be http or https (found: %s)", parsedURL.Scheme)
}
if parsedURL.Host == "" {
return fmt.Errorf("URL must have a host")
}
return nil
}
func validateCertificate(certPEM string) error {
if certPEM == "" {
return fmt.Errorf("certificate cannot be empty")
}
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return fmt.Errorf("cannot parse certificate PEM")
}
if block.Type != pemutil.BlockTypeCertificate {
return fmt.Errorf("PEM block type must be CERTIFICATE (found: %s)", block.Type)
}
_, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("cannot parse X.509 certificate: %w", err)
}
return nil
}

194
pkg/iam/service.go Normal file
View File

@@ -0,0 +1,194 @@
package iam
import (
"context"
"crypto/rsa"
"crypto/x509"
"fmt"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/crypto/passwdhash"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/saml"
)
type (
Service struct {
pg *pg.Client
fm *filemanager.Service
hp *passwdhash.Profile
encryptionKey cipher.EncryptionKey
baseURL string
tokenSecret string
disableSignup bool
invitationTokenValidity time.Duration
passwordResetTokenValidity time.Duration
sessionDuration time.Duration
bucket string
certificate *x509.Certificate
privateKey *rsa.PrivateKey
logger *log.Logger
AccountService *AccountService
OrganizationService *OrganizationService
SessionService *SessionService
AuthService *AuthService
SAMLService *saml.Service
APIKeyService *APIKeyService
AccessManagementService *AccessManagementService
}
Config struct {
DisableSignup bool
InvitationTokenValidity time.Duration
PasswordResetTokenValidity time.Duration
SessionDuration time.Duration
Bucket string
TokenSecret string
BaseURL string
EncryptionKey cipher.EncryptionKey
Certificate *x509.Certificate
PrivateKey *rsa.PrivateKey
Logger *log.Logger
}
)
func NewService(
ctx context.Context,
pgClient *pg.Client,
fm *filemanager.Service,
hp *passwdhash.Profile,
cfg Config,
) (*Service, error) {
if cfg.Bucket == "" {
return nil, fmt.Errorf("bucket is required")
}
if cfg.TokenSecret == "" {
return nil, fmt.Errorf("token secret is required")
}
if cfg.BaseURL == "" {
return nil, fmt.Errorf("base URL is required")
}
if len(cfg.EncryptionKey) == 0 {
return nil, fmt.Errorf("encryption key is required")
}
svc := &Service{
pg: pgClient,
fm: fm,
hp: hp,
baseURL: cfg.BaseURL,
tokenSecret: cfg.TokenSecret,
disableSignup: cfg.DisableSignup,
invitationTokenValidity: cfg.InvitationTokenValidity,
passwordResetTokenValidity: cfg.PasswordResetTokenValidity,
sessionDuration: cfg.SessionDuration,
bucket: cfg.Bucket,
certificate: cfg.Certificate,
privateKey: cfg.PrivateKey,
logger: cfg.Logger,
}
svc.AccountService = NewAccountService(svc)
svc.OrganizationService = NewOrganizationService(svc)
svc.SessionService = NewSessionService(svc)
svc.AuthService = NewAuthService(svc)
svc.APIKeyService = NewAPIKeyService(svc)
svc.AccessManagementService = NewAccessManagementService(svc)
samlService, err := saml.NewService(svc.pg, svc.encryptionKey, svc.baseURL, svc.certificate, svc.privateKey, cfg.Logger)
if err != nil {
return nil, fmt.Errorf("cannot create SAML service: %w", err)
}
svc.SAMLService = samlService
return svc, nil
}
func (s *Service) GetMembership(ctx context.Context, membershipID gid.GID) (*coredata.Membership, error) {
var (
scope = coredata.NewScopeFromObjectID(membershipID)
membership = &coredata.Membership{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := membership.LoadByID(ctx, conn, scope, membershipID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewMembershipNotFoundError(membershipID)
}
return fmt.Errorf("cannot load membership: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *Service) GetInvitation(ctx context.Context, invitationID gid.GID) (*coredata.Invitation, error) {
var (
scope = coredata.NewScopeFromObjectID(invitationID)
invitation = &coredata.Invitation{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := invitation.LoadByID(ctx, conn, scope, invitationID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID)
}
return fmt.Errorf("cannot load invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *Service) GetSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
session := &coredata.Session{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := session.LoadByID(ctx, conn, sessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}

266
pkg/iam/session_service.go Normal file
View File

@@ -0,0 +1,266 @@
// 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 iam
import (
"context"
"fmt"
"net"
"time"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/validator"
)
type (
SessionService struct {
*Service
}
)
func NewSessionService(svc *Service) *SessionService {
return &SessionService{Service: svc}
}
type (
RevokeAllSessionsRequest struct {
CurrentSessionID gid.GID
}
)
func (req RevokeAllSessionsRequest) Validate() error {
v := validator.New()
v.Check(req.CurrentSessionID, "current_session_id", validator.GID(coredata.SessionEntityType))
return v.Error()
}
func (s SessionService) GetSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
var (
session = &coredata.Session{}
now = time.Now()
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
}
if session.ExpireReason != nil {
return NewSessionExpiredError(sessionID)
}
if now.After(session.ExpiredAt) {
session.ExpireReason = ref.Ref(coredata.ExpireReasonIdleTimeout)
session.ExpiredAt = now
session.UpdatedAt = now
if err := session.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update session: %w", err)
}
return NewSessionExpiredError(sessionID)
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}
func (s SessionService) CloseSession(ctx context.Context, sessionID gid.GID) error {
return s.pg.WithTx(
ctx,
func(conn pg.Conn) error {
session := &coredata.Session{}
if err := session.LoadByID(ctx, conn, sessionID); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
if session.ExpireReason != nil {
return NewSessionExpiredError(sessionID)
}
session.ExpireReason = ref.Ref(coredata.ExpireReasonClosed)
session.ExpiredAt = time.Now()
session.UpdatedAt = time.Now()
if err := session.Update(ctx, conn); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
}
func (s SessionService) RevokeSession(ctx context.Context, identityID gid.GID, sessionID gid.GID) error {
now := time.Now()
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
err := user.LoadByID(ctx, tx, identityID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewUserNotFoundError(identityID)
}
return fmt.Errorf("cannot load user: %w", err)
}
session := &coredata.Session{}
err = session.LoadByID(ctx, tx, sessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
// TODO: move to dedicated query instead of LoadByID
if session.UserID != identityID {
return NewSessionNotFoundError(sessionID)
}
if session.ExpireReason != nil {
return NewSessionExpiredError(sessionID)
}
session.ExpireReason = ref.Ref(coredata.ExpireReasonRevoked)
session.ExpiredAt = now
session.UpdatedAt = now
if err := session.Update(ctx, tx); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
}
func (s SessionService) RevokeAllSessions(ctx context.Context, currentSessionID gid.GID) (int64, error) {
var count int64
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
session := coredata.Session{}
err := session.LoadByID(ctx, tx, currentSessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(currentSessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
sessions := coredata.Sessions{}
count, err = sessions.ExpireAllForUserExceptOneSession(ctx, tx, session.UserID, session.ID)
if err != nil {
return fmt.Errorf("cannot expire all sessions: %w", err)
}
return nil
},
)
return count, err
}
func (s SessionService) UpdateSessionInfo(ctx context.Context, sessionID gid.GID, userAgent string, ipAddress net.IP) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
session := &coredata.Session{}
err := session.LoadByID(ctx, tx, sessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
session.UserAgent = userAgent
session.IPAddress = ipAddress
session.UpdatedAt = time.Now()
if err := session.Update(ctx, tx); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
}
func (s SessionService) UpdateSessionData(ctx context.Context, sessionID gid.GID, data coredata.SessionData) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
session := &coredata.Session{}
err := session.LoadByID(ctx, tx, sessionID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot load session: %w", err)
}
session.Data = data
session.UpdatedAt = time.Now()
if err := session.Update(ctx, tx); err != nil {
if err == coredata.ErrResourceNotFound {
return NewSessionNotFoundError(sessionID)
}
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
}

36
pkg/iam/validators.go Normal file
View File

@@ -0,0 +1,36 @@
// 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 iam
import (
"go.probo.inc/probo/pkg/validator"
)
func PasswordValidator() validator.ValidatorFunc {
validators := []validator.ValidatorFunc{
validator.NotEmpty(),
validator.MaxLen(255), // Maximum length set to mitigate DDoS attacks
validator.MinLen(8), // Minimum length to prevent weak passwords
}
return func(value any) *validator.ValidationError {
for _, validator := range validators {
if err := validator(value); err != nil {
return err
}
}
return nil
}
}