auth: publish self-hosted administration foundations
verify / verify (push) Successful in 3m32s

This commit is contained in:
2026-08-27 10:37:38 -04:00
parent a39e8f893c
commit a769d1ea7b
15 changed files with 1124 additions and 100 deletions
+295 -37
View File
@@ -19,18 +19,25 @@ import (
)
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}$`)
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
Personal bool
CreatedAt time.Time
ID string
Slug string
Name string
Status string
Personal bool
Revision int64
CreatedAt, UpdatedAt time.Time
}
type Membership struct {
@@ -41,8 +48,9 @@ type Membership struct {
}
type Team struct {
ID, OrganizationID, Slug, Name string
CreatedAt time.Time
ID, OrganizationID, Slug, Name, Status string
Revision int64
CreatedAt, UpdatedAt time.Time
}
type TeamMembership struct {
@@ -66,35 +74,54 @@ type ApplicationService struct {
}
type Invitation struct {
Digest [32]byte
OrganizationID string
Email, InvitedByUserID string
CreatedAt, ExpiresAt, UsedAt time.Time
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) error
CreateTeam(context.Context, Team) error
AddTeamMember(context.Context, TeamMembership) error
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) error
CreateInvitation(context.Context, Invitation, AuditEvent) error
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
AcceptInvitation(context.Context, [32]byte, string, time.Time) 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
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) {
@@ -107,7 +134,10 @@ func New(repository Repository, options Options) (*Service, error) {
if options.Now == nil {
options.Now = time.Now
}
return &Service{repository: repository, random: options.Random, now: options.Now}, nil
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 {
@@ -126,9 +156,13 @@ func (service *Service) CreateOrganization(ctx context.Context, input CreateOrga
return Organization{}, err
}
now := service.now().UTC()
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Personal: input.Personal, CreatedAt: now}
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}
if err = service.repository.CreateOrganization(ctx, organization, owner); err != nil {
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
@@ -143,30 +177,43 @@ func (service *Service) CreatePersonalOrganization(ctx context.Context, userID,
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 }
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) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
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
}
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 {
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 string) error {
if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) {
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")
}
return service.repository.AddTeamMember(ctx, TeamMembership{TeamID: teamID, UserID: userID, JoinedAt: service.now().UTC()})
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 }
@@ -224,17 +271,37 @@ func (service *Service) CreateApplicationService(ctx context.Context, input Crea
}
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))
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
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{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 {
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
@@ -246,10 +313,15 @@ func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID s
}
digest := sha256.Sum256([]byte(rawToken))
now := service.now().UTC()
if _, err := service.repository.InvitationByDigest(ctx, digest, now); err != nil {
invitation, err := service.repository.InvitationByDigest(ctx, digest, now)
if err != nil {
return err
}
return service.repository.AcceptInvitation(ctx, digest, userID, now)
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) {
@@ -266,8 +338,171 @@ func (service *Service) Teams(ctx context.Context, organizationID, userID string
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 {
@@ -279,3 +514,26 @@ func token(random io.Reader, size int) (string, error) {
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}$`)
+32 -5
View File
@@ -54,18 +54,39 @@ type repositoryStub struct {
acceptedUser string
}
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership) error {
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership, _ AuditEvent) error {
repository.organization = organization
return nil
}
func (*repositoryStub) CreateTeam(context.Context, Team) error { return nil }
func (*repositoryStub) AddTeamMember(context.Context, TeamMembership) error { return nil }
func (repository *repositoryStub) OrganizationByID(context.Context, string) (Organization, error) {
return repository.organization, nil
}
func (*repositoryStub) UpdateOrganization(context.Context, Organization, int64, AuditEvent) error {
return nil
}
func (*repositoryStub) CreateTeam(context.Context, Team, AuditEvent) error { return nil }
func (*repositoryStub) TeamByID(context.Context, string, string) (Team, error) {
return Team{}, nil
}
func (*repositoryStub) UpdateTeam(context.Context, Team, int64, AuditEvent) error { return nil }
func (*repositoryStub) AddTeamMember(context.Context, TeamMembership, AuditEvent) error {
return nil
}
func (*repositoryStub) RemoveTeamMember(context.Context, string, string, AuditEvent) error {
return nil
}
func (*repositoryStub) SetMembershipStatus(context.Context, string, string, string, string, AuditEvent) error {
return nil
}
func (*repositoryStub) RemoveMembership(context.Context, string, string, string, AuditEvent) error {
return nil
}
func (*repositoryStub) CreateProject(context.Context, Project) error { return nil }
func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { return nil }
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
return nil
}
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation) error {
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ AuditEvent) error {
repository.invitation = invitation
return nil
}
@@ -75,7 +96,13 @@ func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte,
}
return repository.invitation, nil
}
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time) error {
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
return nil, nil
}
func (*repositoryStub) RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error {
return nil
}
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {
repository.acceptedUser = userID
return nil
}