540 lines
23 KiB
Go
540 lines
23 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"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
|
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
|
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")
|
|
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 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, AuditEvent) error
|
|
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
|
|
Invitations(context.Context, string, int) ([]Invitation, error)
|
|
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
|
|
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
|
|
MembershipsForUser(context.Context, string) ([]Membership, error)
|
|
TeamsForUser(context.Context, string, string) ([]Team, error)
|
|
}
|
|
|
|
type Options struct {
|
|
Random io.Reader
|
|
Now func() time.Time
|
|
OwnerRole string
|
|
}
|
|
|
|
type Service struct {
|
|
repository Repository
|
|
random io.Reader
|
|
now func() time.Time
|
|
ownerRole string
|
|
}
|
|
|
|
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) {
|
|
return nil, errors.New("organizations: owner role is invalid")
|
|
}
|
|
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
|
|
}
|
|
|
|
type CreateOrganization struct {
|
|
Slug, Name, OwnerUserID string
|
|
Personal bool
|
|
}
|
|
|
|
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, 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) {
|
|
return Organization{}, errors.New("organizations: invalid organization")
|
|
}
|
|
id, err := token(service.random, 18)
|
|
if err != nil {
|
|
return Organization{}, 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.audit(input.OwnerUserID, id, "organization.create", "organization", id, "Organization created")
|
|
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) 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
|
|
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) {
|
|
return "", Invitation{}, errors.New("organizations: invalid invitation")
|
|
}
|
|
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)}
|
|
audit, err := service.audit(invitedBy, organizationID, "invitation.create", "invitation", id, "Organization invitation created")
|
|
if err != nil {
|
|
return "", Invitation{}, err
|
|
}
|
|
if err = service.repository.CreateInvitation(ctx, invitation, audit); 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
|
|
}
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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, 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}$`)
|