Files
web/organizations/organizations.go
T
gamertan c0986168bc
verify / verify (push) Successful in 4m17s
Support atomic organization role sets and owner-managed invitations
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-09-05 00:10:08 -04:00

663 lines
30 KiB
Go

// SPDX-License-Identifier: MPL-2.0
// Package organizations defines storage-neutral organizations, teams,
// memberships, and single-use invitations.
package organizations
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"regexp"
"slices"
"strings"
"time"
)
var (
ErrInvitationNotFound = errors.New("organizations: invitation not found")
ErrMembershipNotFound = errors.New("organizations: membership not found")
ErrMembershipLifecycleUnsupported = errors.New("organizations: optimistic membership lifecycle is unsupported")
ErrOrganizationNotFound = errors.New("organizations: organization not found")
ErrTeamNotFound = errors.New("organizations: team not found")
ErrRevisionConflict = errors.New("organizations: revision conflict")
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access")
ErrOwnedCreationUnsupported = errors.New("organizations: atomic owned organization creation is unsupported")
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
)
type Organization struct {
ID string
Slug string
Name string
Status string
Personal bool
Revision int64
CreatedAt, UpdatedAt time.Time
}
type Membership struct {
OrganizationID string
UserID string
Status string
JoinedAt time.Time
}
type Team struct {
ID, OrganizationID, Slug, Name, Status string
Revision int64
CreatedAt, UpdatedAt time.Time
}
type TeamMembership struct {
TeamID, UserID string
JoinedAt time.Time
}
type Project struct {
ID, OrganizationID, Slug, Name string
CreatedAt time.Time
}
type Environment struct {
ID, OrganizationID, ProjectID, Slug, Name string
CreatedAt time.Time
}
type ApplicationService struct {
ID, OrganizationID, ProjectID, EnvironmentID, Slug, Name string
CreatedAt time.Time
}
type Invitation struct {
ID string
Digest [32]byte
OrganizationID string
Email, InvitedByUserID string
// DirectRole is the legacy single-role form. Use exactly one form.
DirectRole string
DirectRoles []string
// RequiredOwnerRole records the grantor authority to recheck at acceptance.
// Services set it from their trusted configuration, never a request payload.
RequiredOwnerRole string
TeamIDs []string
CreatedAt, ExpiresAt, UsedAt, RevokedAt time.Time
}
type AuditEvent struct {
ID, OrganizationID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string
CreatedAt time.Time
}
type Repository interface {
CreateOrganization(context.Context, Organization, Membership, AuditEvent) error
OrganizationByID(context.Context, string) (Organization, error)
UpdateOrganization(context.Context, Organization, int64, AuditEvent) error
CreateTeam(context.Context, Team, AuditEvent) error
TeamByID(context.Context, string, string) (Team, error)
UpdateTeam(context.Context, Team, int64, AuditEvent) error
AddTeamMember(context.Context, TeamMembership, AuditEvent) error
RemoveTeamMember(context.Context, string, string, AuditEvent) error
SetMembershipStatus(context.Context, string, string, string, string, AuditEvent) error
RemoveMembership(context.Context, string, string, string, AuditEvent) error
CreateProject(context.Context, Project) error
CreateEnvironment(context.Context, Environment) error
CreateApplicationService(context.Context, ApplicationService) error
CreateInvitation(context.Context, Invitation, string, AuditEvent) error
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
Invitations(context.Context, string, int) ([]Invitation, error)
RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
MembershipsForUser(context.Context, string) ([]Membership, error)
TeamsForUser(context.Context, string, string) ([]Team, error)
}
// OptimisticMembershipRepository is implemented by repositories that can
// bind a membership lifecycle mutation to the exact state authorized by the
// caller. Services deliberately do not fall back to the older lifecycle
// methods: a stale fresh-authentication ceremony must fail instead of acting
// on a membership that changed while the ceremony was in progress.
type OptimisticMembershipRepository interface {
ChangeMembershipStatus(context.Context, MembershipStatusChange, string, AuditEvent) error
RemoveMembershipIfCurrent(context.Context, MembershipRemoval, string, AuditEvent) error
}
type Options struct {
Random io.Reader
Now func() time.Time
OwnerRole string
// OwnerManagedInvitations requires a current direct owner to create, revoke,
// and remain the grantor of an invitation until it is accepted.
OwnerManagedInvitations bool
}
type Service struct {
repository Repository
random io.Reader
now func() time.Time
ownerRole string
ownerManagedInvitations bool
}
func New(repository Repository, options Options) (*Service, error) {
if repository == nil {
return nil, errors.New("organizations: repository is required")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) || options.OwnerManagedInvitations && options.OwnerRole == "" {
return nil, errors.New("organizations: owner role is invalid")
}
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole, ownerManagedInvitations: options.OwnerManagedInvitations}, nil
}
type CreateOrganization struct {
Slug, Name, OwnerUserID string
Personal bool
RequestID string
}
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
organization, owner, audit, err := service.prepareOrganization(input)
if err != nil {
return Organization{}, err
}
if err = service.repository.CreateOrganization(ctx, organization, owner, audit); err != nil {
return Organization{}, err
}
return organization, nil
}
func (service *Service) prepareOrganization(input CreateOrganization) (Organization, Membership, AuditEvent, error) {
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
input.Name = strings.TrimSpace(input.Name)
if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) || !boundedOptional(input.RequestID, 128) {
return Organization{}, Membership{}, AuditEvent{}, errors.New("organizations: invalid organization")
}
id, err := token(service.random, 18)
if err != nil {
return Organization{}, Membership{}, AuditEvent{}, err
}
now := service.now().UTC()
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Status: "active", Personal: input.Personal, Revision: 1, CreatedAt: now, UpdatedAt: now}
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
audit, err := service.auditWithRequest(input.OwnerUserID, id, "organization.create", "organization", id, input.RequestID, "Organization created")
if err != nil {
return Organization{}, Membership{}, AuditEvent{}, err
}
return organization, owner, audit, nil
}
func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) {
value := make([]byte, 6)
if _, err := io.ReadFull(service.random, value); err != nil {
return Organization{}, fmt.Errorf("organizations: secure randomness unavailable: %w", err)
}
suffix := hex.EncodeToString(value)
return service.CreateOrganization(ctx, CreateOrganization{Slug: "personal-" + strings.ToLower(suffix), Name: strings.TrimSpace(displayName) + " — Personal", OwnerUserID: userID, Personal: true})
}
type CreateTeam struct{ OrganizationID, Slug, Name, ActorUserID string }
func (service *Service) CreateTeam(ctx context.Context, input CreateTeam) (Team, error) {
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
input.Name = strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ActorUserID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
return Team{}, errors.New("organizations: invalid team")
}
id, err := token(service.random, 18)
if err != nil {
return Team{}, err
}
now := service.now().UTC()
team := Team{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, Status: "active", Revision: 1, CreatedAt: now, UpdatedAt: now}
audit, err := service.audit(input.ActorUserID, input.OrganizationID, "team.create", "team", id, "Team created")
if err != nil {
return Team{}, err
}
if err = service.repository.CreateTeam(ctx, team, audit); err != nil {
return Team{}, err
}
return team, nil
}
func (service *Service) AddTeamMember(ctx context.Context, teamID, userID, actorUserID string) error {
if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) {
return errors.New("organizations: invalid team membership")
}
team, err := service.repository.TeamByID(ctx, "", teamID)
if err != nil {
return err
}
audit, err := service.audit(actorUserID, team.OrganizationID, "team.member.add", "team", teamID, "Team member added")
if err != nil {
return err
}
return service.repository.AddTeamMember(ctx, TeamMembership{TeamID: teamID, UserID: userID, JoinedAt: service.now().UTC()}, audit)
}
type CreateProject struct{ OrganizationID, Slug, Name string }
func (service *Service) CreateProject(ctx context.Context, input CreateProject) (Project, error) {
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.OrganizationID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
return Project{}, errors.New("organizations: invalid project")
}
id, err := token(service.random, 18)
if err != nil {
return Project{}, err
}
project := Project{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
if err = service.repository.CreateProject(ctx, project); err != nil {
return Project{}, err
}
return project, nil
}
type CreateEnvironment struct{ OrganizationID, ProjectID, Slug, Name string }
func (service *Service) CreateEnvironment(ctx context.Context, input CreateEnvironment) (Environment, error) {
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ProjectID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
return Environment{}, errors.New("organizations: invalid environment")
}
id, err := token(service.random, 18)
if err != nil {
return Environment{}, err
}
environment := Environment{ID: id, OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
if err = service.repository.CreateEnvironment(ctx, environment); err != nil {
return Environment{}, err
}
return environment, nil
}
type CreateApplicationService struct{ OrganizationID, ProjectID, EnvironmentID, Slug, Name string }
func (service *Service) CreateApplicationService(ctx context.Context, input CreateApplicationService) (ApplicationService, error) {
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ProjectID) || !idPattern.MatchString(input.EnvironmentID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
return ApplicationService{}, errors.New("organizations: invalid application service")
}
id, err := token(service.random, 18)
if err != nil {
return ApplicationService{}, err
}
application := ApplicationService{ID: id, OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
if err = service.repository.CreateApplicationService(ctx, application); err != nil {
return ApplicationService{}, err
}
return application, nil
}
func (service *Service) Invite(ctx context.Context, organizationID, email, invitedBy string, lifetime time.Duration) (string, Invitation, error) {
return service.InviteWithAccess(ctx, InviteWithAccess{OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, Lifetime: lifetime})
}
type InviteWithAccess struct {
OrganizationID, Email, InvitedByUserID, DirectRole string
DirectRoles []string
RequestID string
TeamIDs []string
Lifetime time.Duration
}
func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAccess) (string, Invitation, error) {
organizationID, email, invitedBy, lifetime := input.OrganizationID, input.Email, input.InvitedByUserID, input.Lifetime
email = strings.ToLower(strings.TrimSpace(email))
input.DirectRole = strings.TrimSpace(input.DirectRole)
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour || input.DirectRole != "" && !safeNamePattern.MatchString(input.DirectRole) || !validIDs(input.TeamIDs, 16) || !boundedOptional(input.RequestID, 128) {
return "", Invitation{}, errors.New("organizations: invalid invitation")
}
roles, err := (Invitation{DirectRole: input.DirectRole, DirectRoles: input.DirectRoles}).RoleNames()
if err != nil {
return "", Invitation{}, err
}
roleRepository, roleSupport := service.repository.(RoleInvitationRepository)
if (len(input.DirectRoles) > 0 || service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole)) && !roleSupport {
return "", Invitation{}, ErrRoleInvitationUnsupported
}
id, err := token(service.random, 18)
if err != nil {
return "", Invitation{}, err
}
raw, err := token(service.random, 32)
if err != nil {
return "", Invitation{}, err
}
now := service.now().UTC()
invitation := Invitation{ID: id, Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, DirectRole: input.DirectRole, TeamIDs: append([]string(nil), input.TeamIDs...), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
if len(input.DirectRoles) > 0 {
invitation.DirectRoles = roles
}
if service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole) {
invitation.RequiredOwnerRole = service.ownerRole
}
audit, err := service.auditWithRequest(invitedBy, organizationID, "invitation.create", "invitation", id, input.RequestID, "Organization invitation created")
if err != nil {
return "", Invitation{}, err
}
if roleSupport {
err = roleRepository.CreateInvitationWithRoles(ctx, invitation, service.ownerRole, audit)
} else {
err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit)
}
if err != nil {
return "", Invitation{}, err
}
return raw, invitation, nil
}
func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID string) error {
if len(rawToken) < 32 || len(rawToken) > 128 || !idPattern.MatchString(userID) {
return ErrInvitationNotFound
}
digest := sha256.Sum256([]byte(rawToken))
now := service.now().UTC()
invitation, err := service.repository.InvitationByDigest(ctx, digest, now)
if err != nil {
return err
}
audit, err := service.audit(userID, invitation.OrganizationID, "invitation.accept", "invitation", invitation.ID, "Organization invitation accepted")
if err != nil {
return err
}
if repository, ok := service.repository.(RoleInvitationRepository); ok {
return repository.AcceptInvitationWithRoles(ctx, digest, userID, service.ownerRole, now, audit)
}
if len(invitation.DirectRoles) > 0 || invitation.RequiredOwnerRole != "" || service.ownerManagedInvitations {
return ErrRoleInvitationUnsupported
}
return service.repository.AcceptInvitation(ctx, digest, userID, now, audit)
}
func (service *Service) Memberships(ctx context.Context, userID string) ([]Membership, error) {
if !idPattern.MatchString(userID) {
return nil, errors.New("organizations: invalid user")
}
return service.repository.MembershipsForUser(ctx, userID)
}
// Members returns a bounded, stable list of active and suspended memberships
// for one organization. Authorization remains an application concern because
// the same storage primitive serves different organization policies.
func (service *Service) Members(ctx context.Context, organizationID string, limit int) ([]Membership, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("organizations: invalid member query")
}
return service.repository.OrganizationMemberships(ctx, organizationID, limit)
}
func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
return nil, errors.New("organizations: invalid team query")
}
return service.repository.TeamsForUser(ctx, organizationID, userID)
}
type UpdateOrganization struct {
ID, Slug, Name, ActorUserID, RequestID string
ExpectedRevision int64
}
func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrganization) (Organization, error) {
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.ID) || !idPattern.MatchString(input.ActorUserID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || input.ExpectedRevision < 1 || !boundedOptional(input.RequestID, 128) {
return Organization{}, errors.New("organizations: invalid organization update")
}
value, err := service.repository.OrganizationByID(ctx, input.ID)
if err != nil {
return Organization{}, err
}
value.Slug, value.Name, value.Revision, value.UpdatedAt = input.Slug, input.Name, input.ExpectedRevision+1, service.now().UTC()
audit, err := service.auditWithRequest(input.ActorUserID, value.ID, "organization.update", "organization", value.ID, input.RequestID, "Organization details updated")
if err != nil {
return Organization{}, err
}
if err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit); err != nil {
return Organization{}, err
}
return value, nil
}
type SetOrganizationStatus struct {
ID, Status, ActorUserID, RequestID string
ExpectedRevision int64
}
func (service *Service) SetOrganizationStatus(ctx context.Context, input SetOrganizationStatus) (Organization, error) {
if !idPattern.MatchString(input.ID) || !idPattern.MatchString(input.ActorUserID) || (input.Status != "active" && input.Status != "archived") || input.ExpectedRevision < 1 || !boundedOptional(input.RequestID, 128) {
return Organization{}, errors.New("organizations: invalid organization status")
}
value, err := service.repository.OrganizationByID(ctx, input.ID)
if err != nil {
return Organization{}, err
}
if value.Personal {
return Organization{}, ErrPersonalOrganization
}
value.Status, value.Revision, value.UpdatedAt = input.Status, input.ExpectedRevision+1, service.now().UTC()
action := "organization.archive"
summary := "Organization archived"
if input.Status == "active" {
action, summary = "organization.reactivate", "Organization reactivated"
}
audit, err := service.auditWithRequest(input.ActorUserID, value.ID, action, "organization", value.ID, input.RequestID, summary)
if err != nil {
return Organization{}, err
}
if err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit); err != nil {
return Organization{}, err
}
return value, nil
}
type UpdateTeam struct {
OrganizationID, ID, Slug, Name, Status, ActorUserID, RequestID string
ExpectedRevision int64
}
func (service *Service) UpdateTeam(ctx context.Context, input UpdateTeam) (Team, error) {
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ID) || !idPattern.MatchString(input.ActorUserID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || (input.Status != "active" && input.Status != "archived") || input.ExpectedRevision < 1 || !boundedOptional(input.RequestID, 128) {
return Team{}, errors.New("organizations: invalid team update")
}
value, err := service.repository.TeamByID(ctx, input.OrganizationID, input.ID)
if err != nil {
return Team{}, err
}
priorStatus := value.Status
value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt = input.Slug, input.Name, input.Status, input.ExpectedRevision+1, service.now().UTC()
action, summary := "team.update", "Team details updated"
if input.Status != priorStatus {
action, summary = "team.archive", "Team archived"
if input.Status == "active" {
action, summary = "team.reactivate", "Team reactivated"
}
}
audit, err := service.auditWithRequest(input.ActorUserID, value.OrganizationID, action, "team", value.ID, input.RequestID, summary)
if err != nil {
return Team{}, err
}
if err = service.repository.UpdateTeam(ctx, value, input.ExpectedRevision, audit); err != nil {
return Team{}, err
}
return value, nil
}
func (service *Service) RemoveTeamMember(ctx context.Context, organizationID, teamID, userID, actorUserID, requestID string) error {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) || !boundedOptional(requestID, 128) {
return errors.New("organizations: invalid team membership removal")
}
audit, err := service.auditWithRequest(actorUserID, organizationID, "team.member.remove", "team", teamID, requestID, "Team member removed")
if err != nil {
return err
}
return service.repository.RemoveTeamMember(ctx, teamID, userID, audit)
}
func (service *Service) SetMembershipStatus(ctx context.Context, organizationID, userID, status, actorUserID, requestID string) error {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) || (status != "active" && status != "suspended") || !boundedOptional(requestID, 128) {
return errors.New("organizations: invalid membership status")
}
if status != "active" && service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes")
}
audit, err := service.auditWithRequest(actorUserID, organizationID, "membership."+status, "membership", userID, requestID, "Organization membership set to "+status)
if err != nil {
return err
}
return service.repository.SetMembershipStatus(ctx, organizationID, userID, status, service.ownerRole, audit)
}
// MembershipStatusChange describes an exact active-to-suspended or
// suspended-to-active transition. ExpectedStatus is part of the authorized
// operation and is checked again inside the repository transaction.
type MembershipStatusChange struct {
OrganizationID, UserID, ExpectedStatus, Status, ActorUserID, RequestID string
}
func (service *Service) ChangeMembershipStatus(ctx context.Context, input MembershipStatusChange) error {
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") ||
(input.Status != "active" && input.Status != "suspended") || input.Status == input.ExpectedStatus ||
!boundedOptional(input.RequestID, 128) {
return errors.New("organizations: invalid membership status change")
}
if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes")
}
repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok {
return ErrMembershipLifecycleUnsupported
}
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership."+input.Status, "membership", input.UserID, input.RequestID, "Organization membership set to "+input.Status)
if err != nil {
return err
}
return repository.ChangeMembershipStatus(ctx, input, service.ownerRole, audit)
}
func (service *Service) RemoveMembership(ctx context.Context, organizationID, userID, actorUserID, requestID string) error {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) || !boundedOptional(requestID, 128) {
return errors.New("organizations: invalid membership removal")
}
if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes")
}
audit, err := service.auditWithRequest(actorUserID, organizationID, "membership.remove", "membership", userID, requestID, "Organization membership removed")
if err != nil {
return err
}
return service.repository.RemoveMembership(ctx, organizationID, userID, service.ownerRole, audit)
}
// MembershipRemoval binds removal to the exact membership state observed by
// the caller before fresh authentication began.
type MembershipRemoval struct {
OrganizationID, UserID, ExpectedStatus, ActorUserID, RequestID string
}
func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error {
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) {
return errors.New("organizations: invalid membership removal")
}
if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes")
}
repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok {
return ErrMembershipLifecycleUnsupported
}
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed")
if err != nil {
return err
}
return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit)
}
func (service *Service) Invitations(ctx context.Context, organizationID string, limit int) ([]Invitation, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("organizations: invalid invitation query")
}
return service.repository.Invitations(ctx, organizationID, limit)
}
func (service *Service) RevokeInvitation(ctx context.Context, organizationID, invitationID, actorUserID, requestID string) error {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitationID) || !idPattern.MatchString(actorUserID) || !boundedOptional(requestID, 128) {
return errors.New("organizations: invalid invitation revocation")
}
now := service.now().UTC()
audit, err := service.auditWithRequest(actorUserID, organizationID, "invitation.revoke", "invitation", invitationID, requestID, "Organization invitation revoked")
if err != nil {
return err
}
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, service.ownerRole, now, audit)
}
func (service *Service) Repository() Repository { return service.repository }
func (service *Service) audit(actor, organizationID, action, resourceType, resourceID, summary string) (AuditEvent, error) {
return service.auditWithRequest(actor, organizationID, action, resourceType, resourceID, "", summary)
}
func (service *Service) auditWithRequest(actor, organizationID, action, resourceType, resourceID, requestID, summary string) (AuditEvent, error) {
if !idPattern.MatchString(actor) || !idPattern.MatchString(organizationID) || !bounded(action, 128) || !bounded(resourceType, 128) || !bounded(resourceID, 128) || !boundedOptional(requestID, 128) || !bounded(summary, 512) {
return AuditEvent{}, errors.New("organizations: invalid audit event")
}
id, err := token(service.random, 18)
if err != nil {
return AuditEvent{}, err
}
return AuditEvent{ID: id, OrganizationID: organizationID, ActorUserID: actor, Action: action, ResourceType: resourceType, ResourceID: resourceID, RequestID: requestID, Summary: summary, CreatedAt: service.now().UTC()}, nil
}
func token(random io.Reader, size int) (string, error) {
value := make([]byte, size)
if _, err := io.ReadFull(random, value); err != nil {
return "", fmt.Errorf("organizations: secure randomness unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func bounded(value string, limit int) bool {
return value != "" && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
}
func boundedOptional(value string, limit int) bool {
return len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
}
func validIDs(values []string, limit int) bool {
if len(values) > limit {
return false
}
seen := make(map[string]struct{}, len(values))
for _, value := range values {
if !idPattern.MatchString(value) {
return false
}
if _, exists := seen[value]; exists {
return false
}
seen[value] = struct{}{}
}
return true
}
var safeNamePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)