diff --git a/CHANGELOG.md b/CHANGELOG.md index b37a88f..64bcc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ # Changelog +## v0.1.0-preview.12 — 2026-09-03 + +- Add a root-local bootstrap transaction that creates the first passkey-only + application owner, non-personal organization, active membership, direct + owner binding, one-time enrollment digest, and secret-free audit records + atomically. +- Fail closed and roll back the entire bootstrap when the application has not + seeded the configured owner role. The raw enrollment token is returned only + after commit and never enters repository state or audit records. + ## v0.1.0-preview.11 — 2026-09-03 - Add expected-user completion for authenticated self-service passkey diff --git a/README.md b/README.md index 6ab3107..e5f61b4 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.11`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.12`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -40,6 +40,7 @@ packages you import. | Users, credentials, permissions, and sessions | [`auth`](auth) + [`authhttp`](authhttp) | | Atomic password-plus-passkey registration | [`account`](account) | | Passkey login and sensitive-operation step-up | [`authwebauthn`](authwebauthn) | +| Atomic first-owner and organization setup | [`bootstrap`](bootstrap) | | Printable single-use recovery codes | [`authrecovery`](authrecovery) | | Private SQLite persistence | [`authsqlite`](authsqlite) | | Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) | @@ -56,14 +57,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.11 +go get gamertan.com/web@v0.1.0-preview.12 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.11 +go get gamertan.com/web/requestmeta@v0.1.0-preview.12 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/authsqlite/bootstrap.go b/authsqlite/bootstrap.go new file mode 100644 index 0000000..bcc5a81 --- /dev/null +++ b/authsqlite/bootstrap.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "errors" + + "gamertan.com/web/bootstrap" +) + +// CreateInitialOwner commits the root-local bootstrap across identity, +// enrollment, organization, membership, owner access, and all audit records. +func (store *Store) CreateInitialOwner(ctx context.Context, setup bootstrap.Setup) error { + user := setup.User + organization := setup.Organization + membership := setup.Membership + binding := setup.OwnerBinding + if !validPasskeyUser(user) || !validEnrollment(setup.Enrollment) || setup.Enrollment.UserID != user.ID || + !validOrganization(organization) || organization.Personal || organization.Status != "active" || organization.Revision != 1 || + membership.OrganizationID != organization.ID || membership.UserID != user.ID || membership.Status != "active" || membership.JoinedAt.IsZero() || + !validOwnerBinding(binding, organization.ID, user.ID) || + !validAuditEvent(setup.AuthAudit) || setup.AuthAudit.ActorUserID != user.ID || setup.AuthAudit.Action != "auth.passkey.bootstrap" || setup.AuthAudit.ResourceType != "user" || setup.AuthAudit.ResourceID != user.ID || + !validOrganizationAudit(setup.OrganizationAudit, organization.ID) || setup.OrganizationAudit.ActorUserID != user.ID || setup.OrganizationAudit.Action != "organization.bootstrap" || setup.OrganizationAudit.ResourceType != "organization" || setup.OrganizationAudit.ResourceID != organization.ID || + !validAccessAudit(setup.AccessAudit) || setup.AccessAudit.OrganizationID != organization.ID || setup.AccessAudit.ActorUserID != user.ID || setup.AccessAudit.Action != "access.binding.grant" || setup.AccessAudit.ResourceType != "binding" || setup.AccessAudit.ResourceID != binding.ID { + return errors.New("authsqlite: invalid initial owner bootstrap") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, setup.Enrollment.Digest[:], user.ID, setup.Enrollment.CreatedAt.Unix(), setup.Enrollment.ExpiresAt.Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,0,NULL,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, user.ID, membership.Status, membership.JoinedAt.Unix()); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, binding.ID, organization.ID, user.ID, binding.Role, user.ID, binding.GrantedAt.Unix(), binding.Role) + if err != nil { + return err + } + if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 { + if rowsErr != nil { + return rowsErr + } + return errors.New("authsqlite: initial owner role has not been seeded") + } + if err = appendAudit(ctx, tx, setup.AuthAudit); err != nil { + return err + } + if err = appendOrganizationAudit(ctx, tx, setup.OrganizationAudit); err != nil { + return err + } + if err = appendAccessAudit(ctx, tx, setup.AccessAudit); err != nil { + return err + } + return tx.Commit() +} + +var _ bootstrap.Repository = (*Store)(nil) diff --git a/authsqlite/bootstrap_test.go b/authsqlite/bootstrap_test.go new file mode 100644 index 0000000..b08cdec --- /dev/null +++ b/authsqlite/bootstrap_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "errors" + "testing" + "time" + + "gamertan.com/web/access" + "gamertan.com/web/auth" + "gamertan.com/web/authwebauthn" + "gamertan.com/web/bootstrap" +) + +func TestInitialOwnerBootstrapCommitsEveryBoundary(t *testing.T) { + store, err := Open(t.TempDir() + "/bootstrap.db") + if err != nil { + t.Fatal(err) + } + defer store.Close() + policy := access.Policy{Roles: map[string]string{"home.owner": "Own the home organization"}, Permissions: map[string]string{"home.manage": "Manage the home organization"}, Grants: map[string][]string{"home.owner": {"home.manage"}}} + accessService, err := access.New(store, policy, access.Options{}) + if err != nil { + t.Fatal(err) + } + if err = accessService.Seed(t.Context()); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC) + service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + created, err := service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"}) + if err != nil { + t.Fatal(err) + } + user, err := store.UserByID(t.Context(), created.User.ID) + if err != nil || user.Email != "cole@example.test" { + t.Fatalf("user=%+v err=%v", user, err) + } + organization, err := store.OrganizationByID(t.Context(), created.Organization.ID) + if err != nil || organization.Personal || organization.Slug != "gamertan" { + t.Fatalf("organization=%+v err=%v", organization, err) + } + memberships, err := store.MembershipsForUser(t.Context(), user.ID) + if err != nil || len(memberships) != 1 || memberships[0].OrganizationID != organization.ID { + t.Fatalf("memberships=%+v err=%v", memberships, err) + } + decision, err := accessService.Authorize(t.Context(), user.ID, access.Scope{OrganizationID: organization.ID}, "home.manage") + if err != nil || !decision.Allowed || decision.Role != "home.owner" { + t.Fatalf("decision=%+v err=%v", decision, err) + } + passkeyService := testBootstrapPasskeyService(t, store, now) + begin, err := passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Initial passkey") + if err != nil || begin.CeremonyToken == "" { + t.Fatalf("begin=%+v err=%v", begin, err) + } + if _, err = passkeyService.BeginEnrollment(t.Context(), created.EnrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) { + t.Fatalf("enrollment replay err=%v", err) + } + var authAudits, accessAudits int + if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&authAudits); err != nil { + t.Fatal(err) + } + if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&accessAudits); err != nil { + t.Fatal(err) + } + if authAudits != 1 || accessAudits != 2 { + t.Fatalf("auth audits=%d access audits=%d", authAudits, accessAudits) + } +} + +func TestInitialOwnerBootstrapRollsBackWithoutSeededRole(t *testing.T) { + store, err := Open(t.TempDir() + "/bootstrap.db") + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Date(2026, 9, 3, 19, 0, 0, 0, time.UTC) + service, err := bootstrap.New(store, bootstrap.Options{OwnerRole: "home.owner", Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + if _, err = service.Start(t.Context(), bootstrap.Input{Username: "cole.owner", Email: "cole@example.test", DisplayName: "Cole Speelman", OrganizationSlug: "gamertan", OrganizationName: "Gamertan"}); err == nil { + t.Fatal("bootstrap succeeded without seeded role") + } + for _, table := range []string{"gwf_users", "gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_passkey_enrollment_tokens", "gwf_audit_events", "gwf_access_audit_events"} { + var count int + if queryErr := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); queryErr != nil || count != 0 { + t.Fatalf("table=%s count=%d err=%v", table, count, queryErr) + } + } +} + +func testBootstrapPasskeyService(t *testing.T, store *Store, now time.Time) *authwebauthn.Service { + t.Helper() + authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "example.test", RPDisplayName: "Example", Origin: "https://example.test", Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + return service +} diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go new file mode 100644 index 0000000..25793a8 --- /dev/null +++ b/bootstrap/bootstrap.go @@ -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") +} diff --git a/bootstrap/bootstrap_test.go b/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..d795364 --- /dev/null +++ b/bootstrap/bootstrap_test.go @@ -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 ", 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) + } +} diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index 3b9d8b4..b290221 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -38,3 +38,8 @@ application concern belongs in the shared module. ceremony and checking its user only after persistence is too late. `FinishRegistrationForUser` now consumes mismatched ceremonies and checks the application-authenticated user before storing a credential. +- First-owner provisioning exposed another cross-package transaction boundary. + `bootstrap` now commits the passkey-only user, enrollment digest, + non-personal organization, membership, direct owner binding, and audits + together. Applications must seed their owner role first and must write the + returned raw token only to a newly created private file. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index b57a78b..f64fb6e 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -19,13 +19,14 @@ install an imagined framework lifecycle around it. | SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery | | One account across organizations and teams | `organizations`, `authsqlite` | Invitation UX, organization naming, and lifecycle policy | | Organization-scoped authorization | `access`, `authsqlite` | Role definitions, resource ownership, and route enforcement | +| First passkey-only owner and home organization | `bootstrap`, `authsqlite` | Root-local command, private token file, enrollment page, and owner-role policy | | Aggregate projections over request records | `analytics` | Collection policy, access control, report UI, and retention | The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.11 +go get gamertan.com/web/requestmeta@v0.1.0-preview.12 go mod verify ``` @@ -61,6 +62,17 @@ quietly changing identity or policy. ## Bootstrap an account without inventing a permanent password +For the first application owner, prefer `bootstrap.Start`. After explicitly +seeding the application's access policy, it creates the active passkey-only +user, non-personal home organization, membership, direct owner binding, +enrollment digest, and audit events in one repository transaction. A missing +owner role or duplicate identity rolls back every row. The application-owned +root-local command writes the returned raw enrollment token once to an +exclusive mode-`0600` file and must never print or log it. + +For applications that still require a temporary password bootstrap, +`auth.GenerateTemporaryPassword` remains available: + `auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic entropy. An application can store that value in a newly created private file and provision an account with `RequirePasswordChange: true`. The library does diff --git a/docs/MODULES.md b/docs/MODULES.md index f597322..1d1eb35 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.11 +go get gamertan.com/web/requestmeta@v0.1.0-preview.12 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/PASSKEYS.md b/docs/PASSKEYS.md index 04af487..5eb1da3 100644 --- a/docs/PASSKEYS.md +++ b/docs/PASSKEYS.md @@ -26,8 +26,11 @@ timestamp, UUID, or counter for the random challenge. ## Application flow -1. A local command calls `Bootstrap` or `Recover` and writes the returned - enrollment token once to a newly created mode-`0600` file. +1. A local command calls `authwebauthn.Bootstrap`, `authwebauthn.Recover`, or + `bootstrap.Start` and writes the returned enrollment token once to a newly + created mode-`0600` file. Use `bootstrap.Start` for the first application + owner so identity, organization membership, direct owner access, and audits + cannot be partially committed. 2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses `navigator.credentials.create` with the returned `public_key` value. 3. The browser posts the credential and opaque ceremony token to a bounded JSON diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow index e3a898f..4a227b4 100644 --- a/scripts/public-snapshot.allow +++ b/scripts/public-snapshot.allow @@ -40,6 +40,8 @@ authsqlite/store_test.go authsqlite/account.go authsqlite/account_test.go authsqlite/access.go +authsqlite/bootstrap.go +authsqlite/bootstrap_test.go authsqlite/organizations.go authsqlite/passkey.go authsqlite/passkey_test.go @@ -48,6 +50,8 @@ authwebauthn/fuzz_test.go authwebauthn/service.go authwebauthn/service_test.go authwebauthn/types.go +bootstrap/bootstrap.go +bootstrap/bootstrap_test.go media/media.go media/media_test.go medialocal/store.go