verify / verify (push) Successful in 3m2s
Sanitized allowlisted snapshot of private source 0acd276fb3423405daf7fff26dedc92b8281e2bd. Adds organization, team, invitation, resource hierarchy, scoped access, and audited break-glass foundations while preserving Preview 1. AI-Assistance: OpenAI Codex assisted implementation, testing, security review, and release preparation. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
282 lines
10 KiB
Go
282 lines
10 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")
|
|
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
|
|
Personal bool
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type Membership struct {
|
|
OrganizationID string
|
|
UserID string
|
|
Status string
|
|
JoinedAt time.Time
|
|
}
|
|
|
|
type Team struct {
|
|
ID, OrganizationID, Slug, Name string
|
|
CreatedAt 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 {
|
|
Digest [32]byte
|
|
OrganizationID string
|
|
Email, InvitedByUserID string
|
|
CreatedAt, ExpiresAt, UsedAt time.Time
|
|
}
|
|
|
|
type Repository interface {
|
|
CreateOrganization(context.Context, Organization, Membership) error
|
|
CreateTeam(context.Context, Team) error
|
|
AddTeamMember(context.Context, TeamMembership) error
|
|
CreateProject(context.Context, Project) error
|
|
CreateEnvironment(context.Context, Environment) error
|
|
CreateApplicationService(context.Context, ApplicationService) error
|
|
CreateInvitation(context.Context, Invitation) error
|
|
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
|
|
AcceptInvitation(context.Context, [32]byte, string, time.Time) error
|
|
MembershipsForUser(context.Context, string) ([]Membership, error)
|
|
TeamsForUser(context.Context, string, string) ([]Team, error)
|
|
}
|
|
|
|
type Options struct {
|
|
Random io.Reader
|
|
Now func() time.Time
|
|
}
|
|
|
|
type Service struct {
|
|
repository Repository
|
|
random io.Reader
|
|
now func() time.Time
|
|
}
|
|
|
|
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
|
|
}
|
|
return &Service{repository: repository, random: options.Random, now: options.Now}, 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, Personal: input.Personal, CreatedAt: now}
|
|
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
|
|
if err = service.repository.CreateOrganization(ctx, organization, owner); 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 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) || !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
|
|
}
|
|
team := Team{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
|
|
if err = service.repository.CreateTeam(ctx, team); err != nil {
|
|
return Team{}, err
|
|
}
|
|
return team, nil
|
|
}
|
|
|
|
func (service *Service) AddTeamMember(ctx context.Context, teamID, userID string) error {
|
|
if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) {
|
|
return errors.New("organizations: invalid team membership")
|
|
}
|
|
return service.repository.AddTeamMember(ctx, TeamMembership{TeamID: teamID, UserID: userID, JoinedAt: service.now().UTC()})
|
|
}
|
|
|
|
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) {
|
|
email = strings.ToLower(strings.TrimSpace(email))
|
|
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
|
return "", Invitation{}, errors.New("organizations: invalid invitation")
|
|
}
|
|
raw, err := token(service.random, 32)
|
|
if err != nil {
|
|
return "", Invitation{}, err
|
|
}
|
|
now := service.now().UTC()
|
|
invitation := Invitation{Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, CreatedAt: now, ExpiresAt: now.Add(lifetime)}
|
|
if err = service.repository.CreateInvitation(ctx, invitation); 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()
|
|
if _, err := service.repository.InvitationByDigest(ctx, digest, now); err != nil {
|
|
return err
|
|
}
|
|
return service.repository.AcceptInvitation(ctx, digest, userID, now)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (service *Service) Repository() Repository { return service.repository }
|
|
|
|
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")
|
|
}
|