This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package bootstrap creates the first application owner and non-personal
|
||||
// organization as one storage transaction. It is intended for a root-local
|
||||
// operator command, not for public registration or a network administration
|
||||
// endpoint.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/authwebauthn"
|
||||
"gamertan.com/web/organizations"
|
||||
)
|
||||
|
||||
const defaultEnrollmentLifetime = 15 * time.Minute
|
||||
|
||||
var (
|
||||
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
rolePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
||||
)
|
||||
|
||||
// Input is the reviewed, non-secret identity and organization metadata from a
|
||||
// local operator command.
|
||||
type Input struct {
|
||||
Username string
|
||||
Email string
|
||||
DisplayName string
|
||||
OrganizationSlug string
|
||||
OrganizationName string
|
||||
}
|
||||
|
||||
// Setup is the complete secret-free state a repository must commit atomically.
|
||||
// Enrollment contains only a digest; the raw token remains in the Result.
|
||||
type Setup struct {
|
||||
User auth.User
|
||||
Enrollment authwebauthn.EnrollmentToken
|
||||
Organization organizations.Organization
|
||||
Membership organizations.Membership
|
||||
OwnerBinding access.Binding
|
||||
AuthAudit auth.AuditEvent
|
||||
OrganizationAudit organizations.AuditEvent
|
||||
AccessAudit access.AuditEvent
|
||||
}
|
||||
|
||||
// Result contains the created public records and the one-time enrollment
|
||||
// secret. Applications must deliver EnrollmentToken through a private channel
|
||||
// and must never log it.
|
||||
type Result struct {
|
||||
User auth.User
|
||||
Organization organizations.Organization
|
||||
EnrollmentToken string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Repository owns the single transaction spanning identity, enrollment,
|
||||
// organization membership, owner access, and their audit events.
|
||||
type Repository interface {
|
||||
CreateInitialOwner(context.Context, Setup) error
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
OwnerRole string
|
||||
EnrollmentLifetime time.Duration
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
ownerRole string
|
||||
enrollmentLifetime time.Duration
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(repository Repository, options Options) (*Service, error) {
|
||||
if repository == nil {
|
||||
return nil, errors.New("bootstrap: repository is required")
|
||||
}
|
||||
if !rolePattern.MatchString(options.OwnerRole) {
|
||||
return nil, errors.New("bootstrap: owner role is invalid")
|
||||
}
|
||||
if options.EnrollmentLifetime == 0 {
|
||||
options.EnrollmentLifetime = defaultEnrollmentLifetime
|
||||
}
|
||||
if options.EnrollmentLifetime < time.Minute || options.EnrollmentLifetime > time.Hour {
|
||||
return nil, errors.New("bootstrap: enrollment lifetime is invalid")
|
||||
}
|
||||
if options.Random == nil {
|
||||
options.Random = rand.Reader
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
return &Service{repository: repository, ownerRole: options.OwnerRole, enrollmentLifetime: options.EnrollmentLifetime, random: options.Random, now: options.Now}, nil
|
||||
}
|
||||
|
||||
// Start atomically creates one active passkey-only owner, one active
|
||||
// non-personal organization, direct owner access, and a single-use enrollment
|
||||
// token. It does not create a session or expose a network bootstrap surface.
|
||||
func (service *Service) Start(ctx context.Context, input Input) (Result, error) {
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.Email = strings.ToLower(strings.TrimSpace(input.Email))
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.OrganizationSlug = strings.ToLower(strings.TrimSpace(input.OrganizationSlug))
|
||||
input.OrganizationName = strings.TrimSpace(input.OrganizationName)
|
||||
if !identifierPattern.MatchString(input.Username) || !canonicalEmail(input.Email) || !bounded(input.DisplayName, 128) || !slugPattern.MatchString(input.OrganizationSlug) || !bounded(input.OrganizationName, 128) {
|
||||
return Result{}, errors.New("bootstrap: invalid owner or organization")
|
||||
}
|
||||
values, err := service.randomValues(7)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
userID, organizationID, bindingID := values[0], values[1], values[2]
|
||||
rawToken := values[3]
|
||||
user := auth.User{ID: userID, Username: input.Username, Email: input.Email, DisplayName: input.DisplayName, Status: "active", CreatedAt: now, UpdatedAt: now}
|
||||
organization := organizations.Organization{ID: organizationID, Slug: input.OrganizationSlug, Name: input.OrganizationName, Status: "active", Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||
enrollment := authwebauthn.EnrollmentToken{Digest: sha256.Sum256([]byte(rawToken)), UserID: userID, CreatedAt: now, ExpiresAt: now.Add(service.enrollmentLifetime)}
|
||||
membership := organizations.Membership{OrganizationID: organizationID, UserID: userID, Status: "active", JoinedAt: now}
|
||||
binding := access.Binding{ID: bindingID, SubjectKind: access.User, SubjectID: userID, Role: service.ownerRole, Scope: access.Scope{OrganizationID: organizationID}, GrantedBy: userID, GrantedAt: now}
|
||||
setup := Setup{
|
||||
User: user,
|
||||
Enrollment: enrollment,
|
||||
Organization: organization,
|
||||
Membership: membership,
|
||||
OwnerBinding: binding,
|
||||
AuthAudit: auth.AuditEvent{ID: values[4], ActorUserID: userID, Action: "auth.passkey.bootstrap", ResourceType: "user", ResourceID: userID, Summary: "A local operator created the initial passkey-only owner and one-time enrollment token.", CreatedAt: now},
|
||||
OrganizationAudit: organizations.AuditEvent{ID: values[5], OrganizationID: organizationID, ActorUserID: userID, Action: "organization.bootstrap", ResourceType: "organization", ResourceID: organizationID, Summary: "A local operator created the initial organization.", CreatedAt: now},
|
||||
AccessAudit: access.AuditEvent{ID: values[6], OrganizationID: organizationID, ActorUserID: userID, Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID, Summary: "The initial owner received direct organization access.", CreatedAt: now},
|
||||
}
|
||||
if err = service.repository.CreateInitialOwner(ctx, setup); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{User: user, Organization: organization, EnrollmentToken: rawToken, ExpiresAt: enrollment.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
func (service *Service) randomValues(count int) ([]string, error) {
|
||||
values := make([]string, count)
|
||||
for index := range values {
|
||||
bytes := make([]byte, 24)
|
||||
if _, err := io.ReadFull(service.random, bytes); err != nil {
|
||||
return nil, fmt.Errorf("bootstrap: secure randomness unavailable: %w", err)
|
||||
}
|
||||
values[index] = base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func canonicalEmail(value string) bool {
|
||||
if value == "" || len(value) > 320 || strings.ContainsAny(value, "\x00\r\n") {
|
||||
return false
|
||||
}
|
||||
address, err := mail.ParseAddress(value)
|
||||
return err == nil && address.Name == "" && address.Address == value
|
||||
}
|
||||
|
||||
func bounded(value string, maximum int) bool {
|
||||
return value != "" && len(value) <= maximum && !strings.ContainsAny(value, "\x00\r\n")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recordingRepository struct {
|
||||
setup Setup
|
||||
err error
|
||||
}
|
||||
|
||||
func (repository *recordingRepository) CreateInitialOwner(_ context.Context, setup Setup) error {
|
||||
repository.setup = setup
|
||||
return repository.err
|
||||
}
|
||||
|
||||
func TestStartBuildsAtomicInitialOwnerSetup(t *testing.T) {
|
||||
repository := new(recordingRepository)
|
||||
now := time.Date(2026, 9, 3, 18, 0, 0, 0, time.UTC)
|
||||
service, err := New(repository, Options{OwnerRole: "home.owner", Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := service.Start(t.Context(), Input{Username: "cole.owner", Email: "COLE@EXAMPLE.TEST", DisplayName: "Cole Speelman", OrganizationSlug: "Gamertan", OrganizationName: "Gamertan"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setup := repository.setup
|
||||
if result.EnrollmentToken == "" || setup.Enrollment.Digest == [32]byte{} || result.User.Email != "cole@example.test" || result.Organization.Personal || result.Organization.Status != "active" {
|
||||
t.Fatalf("result=%+v setup=%+v", result, setup)
|
||||
}
|
||||
if setup.Membership.UserID != result.User.ID || setup.Membership.OrganizationID != result.Organization.ID || setup.OwnerBinding.Role != "home.owner" || setup.OwnerBinding.GrantedBy != result.User.ID {
|
||||
t.Fatalf("membership=%+v binding=%+v", setup.Membership, setup.OwnerBinding)
|
||||
}
|
||||
if setup.AuthAudit.ID == setup.OrganizationAudit.ID || setup.OrganizationAudit.ID == setup.AccessAudit.ID || setup.AuthAudit.Summary == "" || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID {
|
||||
t.Fatalf("audits=%+v %+v %+v", setup.AuthAudit, setup.OrganizationAudit, setup.AccessAudit)
|
||||
}
|
||||
if !result.ExpiresAt.Equal(now.Add(15 * time.Minute)) {
|
||||
t.Fatalf("expires=%v", result.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRejectsUnsafeInputAndDoesNotCommit(t *testing.T) {
|
||||
repository := new(recordingRepository)
|
||||
service, err := New(repository, Options{OwnerRole: "home.owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, input := range []Input{
|
||||
{Username: "x", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
|
||||
{Username: "owner.user", Email: "Owner <owner@example.test>", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"},
|
||||
{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "bad/slug", OrganizationName: "Gamertan"},
|
||||
} {
|
||||
if _, startErr := service.Start(t.Context(), input); startErr == nil {
|
||||
t.Fatalf("unsafe input accepted: %+v", input)
|
||||
}
|
||||
}
|
||||
if repository.setup.User.ID != "" {
|
||||
t.Fatal("repository was called for rejected input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartDoesNotReturnSecretAfterRepositoryFailure(t *testing.T) {
|
||||
repository := &recordingRepository{err: errors.New("commit failed")}
|
||||
service, err := New(repository, Options{OwnerRole: "home.owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := service.Start(t.Context(), Input{Username: "owner.user", Email: "owner@example.test", DisplayName: "Owner", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"})
|
||||
if err == nil || result.EnrollmentToken != "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user