Rewrite identity and access management
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
204
pkg/iam/access_management_service.go
Normal file
204
pkg/iam/access_management_service.go
Normal 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
748
pkg/iam/account_service.go
Normal 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
|
||||
// }
|
||||
41
pkg/iam/action_registry.go
Normal file
41
pkg/iam/action_registry.go
Normal 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
78
pkg/iam/api_key.go
Normal 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
488
pkg/iam/auth_service.go
Normal 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
289
pkg/iam/errors.go
Normal 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
|
||||
}
|
||||
1106
pkg/iam/organization_service.go
Normal file
1106
pkg/iam/organization_service.go
Normal file
File diff suppressed because it is too large
Load Diff
826
pkg/iam/permissions.go
Normal file
826
pkg/iam/permissions.go
Normal 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
168
pkg/iam/saml/attributes.go
Normal 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
90
pkg/iam/saml/errors.go
Normal 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
97
pkg/iam/saml/gc.go
Normal 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
|
||||
},
|
||||
)
|
||||
}
|
||||
87
pkg/iam/saml/saml_metadata.go
Normal file
87
pkg/iam/saml/saml_metadata.go
Normal 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
402
pkg/iam/saml/service.go
Normal 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
68
pkg/iam/saml/sp.go
Normal 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
|
||||
}
|
||||
97
pkg/iam/saml_config_validator.go
Normal file
97
pkg/iam/saml_config_validator.go
Normal 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
194
pkg/iam/service.go
Normal 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
266
pkg/iam/session_service.go
Normal 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
36
pkg/iam/validators.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user