From a769d1ea7bff691793c52b7394a78896333055a6 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Thu, 27 Aug 2026 10:37:38 -0400 Subject: [PATCH] auth: publish self-hosted administration foundations --- CHANGELOG.md | 11 + README.md | 6 +- authsqlite/access.go | 18 +- authsqlite/organizations.go | 421 ++++++++++++++++++++++++++-- authsqlite/passkey.go | 66 ++++- authsqlite/passkey_test.go | 47 ++++ authsqlite/store.go | 45 ++- authsqlite/store_test.go | 93 +++++- authwebauthn/service.go | 64 ++++- authwebauthn/service_test.go | 46 +++ authwebauthn/types.go | 3 + docs/ORGANIZATIONS.md | 27 +- docs/PASSKEYS.md | 8 + organizations/organizations.go | 332 +++++++++++++++++++--- organizations/organizations_test.go | 37 ++- 15 files changed, 1124 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec2dcf..c394670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ## Unreleased +- Add revisioned active/archived lifecycles for organizations and teams, + invitation listing and revocation, membership suspension/removal, team-member + removal, and transactional organization-visible audit events. +- Make archived organizations and teams ineffective during authorization and + preserve the final active direct owner during membership changes. +- Allow invitations to carry one bounded direct role and reviewed team + memberships, applied atomically with single-use acceptance. +- Add an atomic password-to-passkey migration ceremony that stores the first + passkey, retires the password credential, revokes all sessions, and records + the migration audit event in one transaction. + ## v0.1.0-preview.6 — 2026-08-24 - Add an explicit mode-`0640` JSONL option for applications that authorize one diff --git a/README.md b/README.md index e79006c..932684d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Gamertan Web Foundations -> Status: `v0.1.0-preview.6` public preview. APIs may change before a stable +> Status: `v0.1.0-preview.7` public preview. APIs may change before a stable > release; Linux is the maintained release platform. Small, composable Go packages for the unglamorous boundaries of a careful web @@ -24,14 +24,14 @@ Pin the preview in an application module, then import only the packages that application needs: ```bash -go get gamertan.com/web@v0.1.0-preview.6 +go get gamertan.com/web@v0.1.0-preview.7 go mod verify ``` An application may also name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.6 +go get gamertan.com/web/requestmeta@v0.1.0-preview.7 ``` The version belongs to the `gamertan.com/web` module. Go compiles and links diff --git a/authsqlite/access.go b/authsqlite/access.go index 10e0da1..0bb7245 100644 --- a/authsqlite/access.go +++ b/authsqlite/access.go @@ -53,9 +53,9 @@ func (store *Store) Grant(ctx context.Context, binding access.Binding) error { } defer tx.Rollback() var exists int - query := `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'` + query := `SELECT COUNT(*) FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active' WHERE m.organization_id=? AND m.user_id=? AND m.status='active'` if binding.SubjectKind == access.Team { - query = `SELECT COUNT(*) FROM gwf_teams WHERE organization_id=? AND id=?` + query = `SELECT COUNT(*) FROM gwf_teams t JOIN gwf_organizations o ON o.id=t.organization_id AND o.status='active' WHERE t.organization_id=? AND t.id=? AND t.status='active'` } if err = tx.QueryRowContext(ctx, query, binding.Scope.OrganizationID, binding.SubjectID).Scan(&exists); err != nil { return err @@ -63,7 +63,7 @@ func (store *Store) Grant(ctx context.Context, binding access.Binding) error { if exists != 1 { return errors.New("authsqlite: access subject is not active in organization") } - if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'`, binding.Scope.OrganizationID, binding.GrantedBy).Scan(&exists); err != nil || exists != 1 { + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id AND o.status='active' WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, binding.Scope.OrganizationID, binding.GrantedBy).Scan(&exists); err != nil || exists != 1 { if err != nil { return err } @@ -110,9 +110,10 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI } rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_kind,b.subject_id,b.role_name,b.project_id,b.environment_id,b.service_id,b.granted_by_user_id,b.granted_at FROM gwf_access_bindings b + JOIN gwf_organizations o ON o.id=b.organization_id AND o.status='active' WHERE b.organization_id=? AND b.revoked_at IS NULL AND EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=b.organization_id AND m.user_id=? AND m.status='active') - AND ((b.subject_kind='user' AND b.subject_id=?) OR (b.subject_kind='team' AND EXISTS (SELECT 1 FROM gwf_team_members tm JOIN gwf_teams t ON t.id=tm.team_id WHERE tm.team_id=b.subject_id AND tm.user_id=? AND t.organization_id=b.organization_id))) + AND ((b.subject_kind='user' AND b.subject_id=?) OR (b.subject_kind='team' AND EXISTS (SELECT 1 FROM gwf_team_members tm JOIN gwf_teams t ON t.id=tm.team_id WHERE tm.team_id=b.subject_id AND tm.user_id=? AND t.organization_id=b.organization_id AND t.status='active'))) ORDER BY b.id`, organizationID, userID, userID, userID) if err != nil { return nil, err @@ -142,6 +143,13 @@ func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlas return err } defer tx.Rollback() + var active int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organizations o JOIN gwf_organization_memberships m ON m.organization_id=o.id WHERE o.id=? AND o.status='active' AND m.user_id=? AND m.status='active'`, grant.OrganizationID, grant.UserID).Scan(&active); err != nil { + return err + } + if active != 1 { + return errors.New("authsqlite: break-glass principal is not active in organization") + } if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_break_glass(id,organization_id,user_id,permission_name,reason,created_at,expires_at) VALUES(?,?,?,?,?,?,?)`, grant.ID, grant.OrganizationID, grant.UserID, grant.Permission, grant.Reason, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil { return err } @@ -155,7 +163,7 @@ func (store *Store) ActiveBreakGlass(ctx context.Context, organizationID, userID if !opaqueID(organizationID) || !opaqueID(userID) || now.IsZero() { return nil, errors.New("authsqlite: invalid break-glass query") } - rows, err := store.db.QueryContext(ctx, `SELECT id,permission_name,reason,created_at,expires_at FROM gwf_break_glass WHERE organization_id=? AND user_id=? AND expires_at>? ORDER BY expires_at`, organizationID, userID, now.Unix()) + rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.permission_name,b.reason,b.created_at,b.expires_at FROM gwf_break_glass b JOIN gwf_organizations o ON o.id=b.organization_id AND o.status='active' JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.user_id AND m.status='active' WHERE b.organization_id=? AND b.user_id=? AND b.expires_at>? ORDER BY b.expires_at`, organizationID, userID, now.Unix()) if err != nil { return nil, err } diff --git a/authsqlite/organizations.go b/authsqlite/organizations.go index dbebf8b..e821b37 100644 --- a/authsqlite/organizations.go +++ b/authsqlite/organizations.go @@ -5,14 +5,15 @@ package authsqlite import ( "context" "database/sql" + "encoding/json" "errors" "time" "gamertan.com/web/organizations" ) -func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership) error { - if !opaqueID(organization.ID) || !slugValue(organization.Slug) || !text(organization.Name, 128, false) || organization.CreatedAt.IsZero() || owner.OrganizationID != organization.ID || !opaqueID(owner.UserID) || owner.Status != "active" || owner.JoinedAt.IsZero() { +func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership, audit organizations.AuditEvent) error { + if !validOrganization(organization) || owner.OrganizationID != organization.ID || !opaqueID(owner.UserID) || owner.Status != "active" || owner.JoinedAt.IsZero() || !validOrganizationAudit(audit, organization.ID) { return errors.New("authsqlite: invalid organization") } tx, err := store.db.BeginTx(ctx, nil) @@ -24,38 +25,64 @@ func (store *Store) CreateOrganization(ctx context.Context, organization organiz if organization.Personal { personalOwner = owner.UserID } - if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at) VALUES(?,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, organization.CreatedAt.Unix()); err != nil { + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, 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(?,?,?,?)`, owner.OrganizationID, owner.UserID, owner.Status, owner.JoinedAt.Unix()); err != nil { return err } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } return tx.Commit() } -func (store *Store) CreateTeam(ctx context.Context, team organizations.Team) error { - if !opaqueID(team.ID) || !opaqueID(team.OrganizationID) || !slugValue(team.Slug) || !text(team.Name, 128, false) || team.CreatedAt.IsZero() { +func (store *Store) CreateTeam(ctx context.Context, team organizations.Team, audit organizations.AuditEvent) error { + if !validTeam(team) || !validOrganizationAudit(audit, team.OrganizationID) { return errors.New("authsqlite: invalid team") } - _, err := store.db.ExecContext(ctx, `INSERT INTO gwf_teams(id,organization_id,slug,name,created_at) VALUES(?,?,?,?,?)`, team.ID, team.OrganizationID, team.Slug, team.Name, team.CreatedAt.Unix()) - return err + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `INSERT INTO gwf_teams(id,organization_id,slug,name,created_at,status,revision,updated_at) SELECT ?,?,?,?,?,?,?,? FROM gwf_organizations WHERE id=? AND status='active'`, team.ID, team.OrganizationID, team.Slug, team.Name, team.CreatedAt.Unix(), team.Status, team.Revision, team.UpdatedAt.Unix(), team.OrganizationID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrOrganizationNotFound + } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() } -func (store *Store) AddTeamMember(ctx context.Context, membership organizations.TeamMembership) error { - if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() { +func (store *Store) AddTeamMember(ctx context.Context, membership organizations.TeamMembership, audit organizations.AuditEvent) error { + if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) { return errors.New("authsqlite: invalid team membership") } - result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at) + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at) SELECT t.id,?,? FROM gwf_teams t JOIN gwf_organization_memberships m ON m.organization_id=t.organization_id AND m.user_id=? AND m.status='active' - WHERE t.id=? ON CONFLICT(team_id,user_id) DO UPDATE SET joined_at=gwf_team_members.joined_at`, membership.UserID, membership.JoinedAt.Unix(), membership.UserID, membership.TeamID) + JOIN gwf_organizations o ON o.id=t.organization_id AND o.status='active' + WHERE t.id=? AND t.status='active' AND t.organization_id=? ON CONFLICT(team_id,user_id) DO UPDATE SET joined_at=gwf_team_members.joined_at`, membership.UserID, membership.JoinedAt.Unix(), membership.UserID, membership.TeamID, audit.OrganizationID) if err != nil { return err } if changed, _ := result.RowsAffected(); changed != 1 { return organizations.ErrMembershipNotFound } - return nil + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() } func (store *Store) CreateProject(ctx context.Context, project organizations.Project) error { @@ -96,20 +123,35 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or return nil } -func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation) error { - if zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() { +func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, audit organizations.AuditEvent) error { + if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) { return errors.New("authsqlite: invalid invitation") } - result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) - SELECT ?,?,?,?,?,? FROM gwf_organization_memberships - WHERE organization_id=? AND user_id=? AND status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.OrganizationID, invitation.InvitedByUserID) + teamIDs, err := json.Marshal(invitation.TeamIDs) + if err != nil { + return err + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at,id,direct_role,team_ids_json) + SELECT ?,?,?,?,?,?,?,?,? FROM gwf_organization_memberships m JOIN gwf_organizations o ON o.id=m.organization_id + WHERE m.organization_id=? AND m.user_id=? AND m.status='active' AND o.status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.ID, invitation.DirectRole, teamIDs, invitation.OrganizationID, invitation.InvitedByUserID) if err != nil { return err } if changed, _ := result.RowsAffected(); changed != 1 { return organizations.ErrMembershipNotFound } - return nil + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() } func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now time.Time) (organizations.Invitation, error) { @@ -118,7 +160,8 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now } var invitation organizations.Invitation var created, expires int64 - err := store.db.QueryRowContext(ctx, `SELECT organization_id,email_normalized,invited_by_user_id,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &created, &expires) + var teamIDs []byte + err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.ID, &invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &invitation.DirectRole, &teamIDs, &created, &expires) if errors.Is(err, sql.ErrNoRows) { return organizations.Invitation{}, organizations.ErrInvitationNotFound } @@ -126,13 +169,16 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now return organizations.Invitation{}, err } invitation.Digest = digest + if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) { + return organizations.Invitation{}, organizations.ErrInvitationNotFound + } invitation.CreatedAt = time.Unix(created, 0).UTC() invitation.ExpiresAt = time.Unix(expires, 0).UTC() return invitation, nil } -func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time) error { - if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() { +func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time, audit organizations.AuditEvent) error { + if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) { return organizations.ErrInvitationNotFound } tx, err := store.db.BeginTx(ctx, nil) @@ -140,8 +186,9 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI return err } defer tx.Rollback() - var organizationID string - err = tx.QueryRowContext(ctx, `SELECT i.organization_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized WHERE i.token_hash=? AND i.used_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&organizationID) + var invitationID, organizationID, directRole, invitedBy string + var teamIDsJSON []byte + err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized JOIN gwf_organizations o ON o.id=i.organization_id AND o.status='active' WHERE i.token_hash=? AND i.used_at IS NULL AND i.revoked_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy) if errors.Is(err, sql.ErrNoRows) { return organizations.ErrInvitationNotFound } @@ -151,6 +198,30 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?) ON CONFLICT(organization_id,user_id) DO UPDATE SET status='active'`, organizationID, userID, acceptedAt.Unix()); err != nil { return err } + var teamIDs []string + if json.Unmarshal(teamIDsJSON, &teamIDs) != nil || !validInvitationTeamIDs(teamIDs) { + return organizations.ErrInvitationNotFound + } + if err = validateInvitationTeams(ctx, tx, organizationID, teamIDs); err != nil { + return organizations.ErrInvitationNotFound + } + for _, teamID := range teamIDs { + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at) VALUES(?,?,?) ON CONFLICT(team_id,user_id) DO NOTHING`, teamID, userID, acceptedAt.Unix()); err != nil { + return err + } + } + if directRole != "" { + if !safeName(directRole) { + return organizations.ErrInvitationNotFound + } + 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=?`, "invite-"+invitationID, organizationID, userID, directRole, invitedBy, acceptedAt.Unix(), directRole) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrInvitationNotFound + } + } result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET used_at=? WHERE token_hash=? AND used_at IS NULL`, acceptedAt.Unix(), digest[:]) if err != nil { return err @@ -158,6 +229,12 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI if changed, _ := result.RowsAffected(); changed != 1 { return organizations.ErrInvitationNotFound } + if organizationID != audit.OrganizationID { + return organizations.ErrInvitationNotFound + } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } return tx.Commit() } @@ -188,7 +265,7 @@ func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID str if !opaqueID(organizationID) || !opaqueID(userID) { return nil, errors.New("authsqlite: invalid team query") } - rows, err := store.db.QueryContext(ctx, `SELECT t.id,t.slug,t.name,t.created_at FROM gwf_teams t JOIN gwf_team_members tm ON tm.team_id=t.id WHERE t.organization_id=? AND tm.user_id=? ORDER BY t.slug`, organizationID, userID) + rows, err := store.db.QueryContext(ctx, `SELECT t.id,t.slug,t.name,t.status,t.revision,t.created_at,t.updated_at FROM gwf_teams t JOIN gwf_team_members tm ON tm.team_id=t.id JOIN gwf_organizations o ON o.id=t.organization_id WHERE t.organization_id=? AND tm.user_id=? AND t.status='active' AND o.status='active' ORDER BY t.slug`, organizationID, userID) if err != nil { return nil, err } @@ -196,17 +273,309 @@ func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID str var result []organizations.Team for rows.Next() { var team organizations.Team - var created int64 - if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &created); err != nil { + var created, updated int64 + if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &team.Status, &team.Revision, &created, &updated); err != nil { return nil, err } team.OrganizationID = organizationID team.CreatedAt = time.Unix(created, 0).UTC() + team.UpdatedAt = time.Unix(updated, 0).UTC() result = append(result, team) } return result, rows.Err() } +func (store *Store) OrganizationByID(ctx context.Context, organizationID string) (organizations.Organization, error) { + if !opaqueID(organizationID) { + return organizations.Organization{}, organizations.ErrOrganizationNotFound + } + var value organizations.Organization + var personal int + var created, updated int64 + err := store.db.QueryRowContext(ctx, `SELECT id,slug,name,status,personal,revision,created_at,updated_at FROM gwf_organizations WHERE id=?`, organizationID).Scan(&value.ID, &value.Slug, &value.Name, &value.Status, &personal, &value.Revision, &created, &updated) + if errors.Is(err, sql.ErrNoRows) { + return organizations.Organization{}, organizations.ErrOrganizationNotFound + } + if err != nil { + return organizations.Organization{}, err + } + value.Personal = personal == 1 + value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() + return value, nil +} + +func (store *Store) UpdateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, audit organizations.AuditEvent) error { + if !validOrganization(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.ID) { + return errors.New("authsqlite: invalid organization update") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE gwf_organizations SET slug=?,name=?,status=?,revision=?,updated_at=? WHERE id=? AND revision=?`, value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt.Unix(), value.ID, expectedRevision) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrRevisionConflict + } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) TeamByID(ctx context.Context, organizationID, teamID string) (organizations.Team, error) { + if !opaqueID(teamID) || organizationID != "" && !opaqueID(organizationID) { + return organizations.Team{}, organizations.ErrTeamNotFound + } + query := `SELECT id,organization_id,slug,name,status,revision,created_at,updated_at FROM gwf_teams WHERE id=?` + args := []any{teamID} + if organizationID != "" { + query += ` AND organization_id=?` + args = append(args, organizationID) + } + var value organizations.Team + var created, updated int64 + err := store.db.QueryRowContext(ctx, query, args...).Scan(&value.ID, &value.OrganizationID, &value.Slug, &value.Name, &value.Status, &value.Revision, &created, &updated) + if errors.Is(err, sql.ErrNoRows) { + return organizations.Team{}, organizations.ErrTeamNotFound + } + if err != nil { + return organizations.Team{}, err + } + value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() + return value, nil +} + +func (store *Store) UpdateTeam(ctx context.Context, value organizations.Team, expectedRevision int64, audit organizations.AuditEvent) error { + if !validTeam(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.OrganizationID) { + return errors.New("authsqlite: invalid team update") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE gwf_teams SET slug=?,name=?,status=?,revision=?,updated_at=? WHERE id=? AND organization_id=? AND revision=?`, value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt.Unix(), value.ID, value.OrganizationID, expectedRevision) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrRevisionConflict + } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) RemoveTeamMember(ctx context.Context, teamID, userID string, audit organizations.AuditEvent) error { + if !opaqueID(teamID) || !opaqueID(userID) || !validOrganizationAudit(audit, audit.OrganizationID) { + return organizations.ErrMembershipNotFound + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE team_id=? AND user_id=? AND EXISTS (SELECT 1 FROM gwf_teams WHERE id=? AND organization_id=?)`, teamID, userID, teamID, audit.OrganizationID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrMembershipNotFound + } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, userID, status, ownerRole string, audit organizations.AuditEvent) error { + if !opaqueID(organizationID) || !opaqueID(userID) || (status != "active" && status != "suspended") || status != "active" && !safeName(ownerRole) || !validOrganizationAudit(audit, organizationID) { + return organizations.ErrMembershipNotFound + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if status != "active" { + if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil { + return err + } + } + result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=? WHERE organization_id=? AND user_id=?`, status, organizationID, userID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrMembershipNotFound + } + if status != "active" { + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, userID, organizationID); err != nil { + return err + } + } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID, ownerRole string, audit organizations.AuditEvent) error { + if !opaqueID(organizationID) || !opaqueID(userID) || !safeName(ownerRole) || !validOrganizationAudit(audit, organizationID) { + return organizations.ErrMembershipNotFound + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_team_members WHERE user_id=? AND team_id IN (SELECT id FROM gwf_teams WHERE organization_id=?)`, userID, organizationID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=? WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND revoked_at IS NULL`, audit.ActorUserID, audit.CreatedAt.Unix(), organizationID, userID); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `DELETE FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrMembershipNotFound + } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) error { + var targetIsOwner int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=? AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&targetIsOwner); err != nil { + return err + } + if targetIsOwner == 0 { + return nil + } + var activeOwners int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id) FROM gwf_access_bindings b JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id AND m.status='active' WHERE b.organization_id=? AND b.subject_kind='user' AND b.role_name=? AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL AND b.revoked_at IS NULL`, organizationID, ownerRole).Scan(&activeOwners); err != nil { + return err + } + if activeOwners <= 1 { + return organizations.ErrLastOwner + } + return nil +} + +func (store *Store) Invitations(ctx context.Context, organizationID string, limit int) ([]organizations.Invitation, error) { + if !opaqueID(organizationID) || limit < 1 || limit > 1000 { + return nil, errors.New("authsqlite: invalid invitation query") + } + rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC LIMIT ?`, organizationID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + result := make([]organizations.Invitation, 0) + for rows.Next() { + var value organizations.Invitation + var created, expires, used, revoked int64 + var teamIDs []byte + if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &created, &expires, &used, &revoked); err != nil { + return nil, err + } + if json.Unmarshal(teamIDs, &value.TeamIDs) != nil || !validInvitationTeamIDs(value.TeamIDs) { + return nil, errors.New("authsqlite: stored invitation is invalid") + } + value.OrganizationID = organizationID + value.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC() + if used != 0 { + value.UsedAt = time.Unix(used, 0).UTC() + } + if revoked != 0 { + value.RevokedAt = time.Unix(revoked, 0).UTC() + } + result = append(result, value) + } + return result, rows.Err() +} + +func validInvitationTeamIDs(teamIDs []string) bool { + if len(teamIDs) > 16 { + return false + } + seen := make(map[string]struct{}, len(teamIDs)) + for _, teamID := range teamIDs { + if !opaqueID(teamID) { + return false + } + if _, exists := seen[teamID]; exists { + return false + } + seen[teamID] = struct{}{} + } + return true +} + +func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID string, teamIDs []string) error { + for _, teamID := range teamIDs { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_teams WHERE id=? AND organization_id=? AND status='active'`, teamID, organizationID).Scan(&count); err != nil { + return err + } + if count != 1 { + return organizations.ErrTeamNotFound + } + } + return nil +} + +func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID string, revokedAt time.Time, audit organizations.AuditEvent) error { + if !opaqueID(organizationID) || !opaqueID(invitationID) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) { + return organizations.ErrInvitationNotFound + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrInvitationNotFound + } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func validOrganization(value organizations.Organization) bool { + return opaqueID(value.ID) && slugValue(value.Slug) && text(value.Name, 128, false) && (value.Status == "active" || value.Status == "archived") && value.Revision > 0 && !value.CreatedAt.IsZero() && !value.UpdatedAt.IsZero() +} + +func validTeam(value organizations.Team) bool { + return opaqueID(value.ID) && opaqueID(value.OrganizationID) && slugValue(value.Slug) && text(value.Name, 128, false) && (value.Status == "active" || value.Status == "archived") && value.Revision > 0 && !value.CreatedAt.IsZero() && !value.UpdatedAt.IsZero() +} + +func validOrganizationAudit(value organizations.AuditEvent, organizationID string) bool { + return opaqueID(value.ID) && opaqueID(organizationID) && value.OrganizationID == organizationID && opaqueID(value.ActorUserID) && text(value.Action, 128, false) && text(value.ResourceType, 128, false) && text(value.ResourceID, 128, false) && text(value.RequestID, 128, true) && text(value.Summary, 512, false) && !value.CreatedAt.IsZero() +} + +func appendOrganizationAudit(ctx context.Context, tx *sql.Tx, value organizations.AuditEvent) error { + _, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, value.ID, value.OrganizationID, value.ActorUserID, value.Action, value.ResourceType, value.ResourceID, value.RequestID, value.Summary, value.CreatedAt.Unix()) + return err +} + func slugValue(value string) bool { if len(value) < 2 || len(value) > 63 || (value[0] < 'a' || value[0] > 'z') && (value[0] < '0' || value[0] > '9') { return false diff --git a/authsqlite/passkey.go b/authsqlite/passkey.go index 5afd869..f5914c2 100644 --- a/authsqlite/passkey.go +++ b/authsqlite/passkey.go @@ -93,6 +93,17 @@ func (store *Store) CredentialsByUserID(ctx context.Context, userID string) ([]a return credentials, rows.Err() } +func (store *Store) PasswordCredentialExists(ctx context.Context, userID string) (bool, error) { + if !opaqueID(userID) { + return false, auth.ErrUserNotFound + } + var count int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_password_credentials WHERE user_id=?`, userID).Scan(&count); err != nil { + return false, err + } + return count == 1, nil +} + func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error { if !validCredential(credential, true) || !validAuditEvent(audit) { return errors.New("authsqlite: invalid passkey credential") @@ -118,6 +129,56 @@ func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn. return tx.Commit() } +func (store *Store) SaveCredentialAndRetirePassword(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error { + if !validCredential(credential, true) || !validAuditEvent(audit) || audit.ActorUserID != credential.UserID || audit.Action != "auth.passkey.migrate" { + return errors.New("authsqlite: invalid passkey migration") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var credentialCount, passwordCount int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, credential.UserID).Scan(&credentialCount); err != nil { + return err + } + if credentialCount >= maxPasskeysPerUser { + return errors.New("authsqlite: passkey credential limit reached") + } + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_password_credentials WHERE user_id=?`, credential.UserID).Scan(&passwordCount); err != nil { + return err + } + if passwordCount != 1 { + return authwebauthn.ErrPasswordNotAvailable + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, credential.UserID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `DELETE FROM gwf_password_credentials WHERE user_id=?`, credential.UserID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return authwebauthn.ErrPasswordNotAvailable + } + if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, credential.CreatedAt.Unix(), credential.UserID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, credential.UserID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, credential.UserID); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, credential.UserID); err != nil { + return err + } + if err = appendAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + func (store *Store) UpdateCredential(ctx context.Context, credential authwebauthn.Credential) error { if !validCredential(credential, false) || credential.LastUsedAt.IsZero() { return errors.New("authsqlite: invalid passkey credential update") @@ -311,9 +372,10 @@ func validCredential(credential authwebauthn.Credential, requireLabel bool) bool func validCeremony(ceremony authwebauthn.Ceremony) bool { validKind := ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin || ceremony.Kind == authwebauthn.CeremonyApproval validUser := ceremony.Kind == authwebauthn.CeremonyLogin && ceremony.UserID == "" || opaqueID(ceremony.UserID) - validLabel := ceremony.Kind == authwebauthn.CeremonyRegistration && text(ceremony.Label, 80, false) || ceremony.Kind != authwebauthn.CeremonyRegistration && ceremony.Label == "" + registrationKind := ceremony.Kind == authwebauthn.CeremonyRegistration + validLabel := registrationKind && text(ceremony.Label, 80, false) || !registrationKind && ceremony.Label == "" zeroBinding := zeroDigest(ceremony.BindingDigest) - validBinding := ceremony.Kind == authwebauthn.CeremonyApproval && !zeroBinding || ceremony.Kind != authwebauthn.CeremonyApproval && zeroBinding + validBinding := ceremony.Kind == authwebauthn.CeremonyApproval && !zeroBinding || ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin && zeroBinding return !zeroDigest(ceremony.Digest) && validKind && validUser && validLabel && validBinding && len(ceremony.SessionData) > 0 && len(ceremony.SessionData) <= maxCeremonySessionBytes && json.Valid(ceremony.SessionData) && !ceremony.CreatedAt.IsZero() && ceremony.ExpiresAt.After(ceremony.CreatedAt) } diff --git a/authsqlite/passkey_test.go b/authsqlite/passkey_test.go index 52f6acf..e736d2e 100644 --- a/authsqlite/passkey_test.go +++ b/authsqlite/passkey_test.go @@ -92,6 +92,53 @@ func TestPasskeyCeremonyIsConsumedExactlyOnceConcurrently(t *testing.T) { } } +func TestPasskeyMigrationAtomicallyRetiresPasswordAndSessions(t *testing.T) { + store, err := Open(t.TempDir() + "/auth.db") + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "migrate.me", Email: "migrate@example.test", DisplayName: "Migration Test", Password: "legacy password credential"}) + if err != nil { + t.Fatal(err) + } + session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + id := bytes.Repeat([]byte{7}, 32) + encoded, err := json.Marshal(wa.Credential{ID: id, PublicKey: []byte{1, 2, 3}}) + if err != nil { + t.Fatal(err) + } + credential := authwebauthn.Credential{ID: id, UserID: user.ID, Label: "Primary passkey", Data: encoded, CreatedAt: now} + audit := auth.AuditEvent{ID: "migration-audit", ActorUserID: user.ID, Action: "auth.passkey.migrate", ResourceType: "passkey", ResourceID: "credential", Summary: "migration", CreatedAt: now} + if err = store.SaveCredentialAndRetirePassword(t.Context(), credential, audit); err != nil { + t.Fatal(err) + } + if exists, existsErr := store.PasswordCredentialExists(t.Context(), user.ID); existsErr != nil || exists { + t.Fatalf("password exists=%v err=%v", exists, existsErr) + } + if _, _, err = authService.Authenticate(t.Context(), user.Username, "legacy password credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) { + t.Fatalf("legacy password still authenticates: %v", err) + } + if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) { + t.Fatalf("session survived migration: %v", err) + } + credentials, err := store.CredentialsByUserID(t.Context(), user.ID) + if err != nil || len(credentials) != 1 || !bytes.Equal(credentials[0].ID, id) { + t.Fatalf("credentials=%+v err=%v", credentials, err) + } + if err = store.SaveCredentialAndRetirePassword(t.Context(), credential, audit); !errors.Is(err, authwebauthn.ErrPasswordNotAvailable) { + t.Fatalf("migration replay err=%v", err) + } +} + func testAudit(id, action, resourceID string, now time.Time) auth.AuditEvent { return auth.AuditEvent{ID: id, Action: action, ResourceType: "user", ResourceID: resourceID, Summary: "test", CreatedAt: now} } diff --git a/authsqlite/store.go b/authsqlite/store.go index 931238a..9c03336 100644 --- a/authsqlite/store.go +++ b/authsqlite/store.go @@ -94,16 +94,16 @@ func (store *Store) Migrate(ctx context.Context) error { `CREATE INDEX IF NOT EXISTS gwf_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`, `CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`, `CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`, - `CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`, `CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`, - `CREATE TABLE IF NOT EXISTS gwf_teams (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`, + `CREATE TABLE IF NOT EXISTS gwf_teams (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived')), revision INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`, `CREATE TABLE IF NOT EXISTS gwf_team_members (team_id TEXT NOT NULL REFERENCES gwf_teams(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, joined_at INTEGER NOT NULL, PRIMARY KEY(team_id,user_id))`, `CREATE INDEX IF NOT EXISTS gwf_team_members_user ON gwf_team_members(user_id,team_id)`, `CREATE TABLE IF NOT EXISTS gwf_projects (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`, `CREATE TABLE IF NOT EXISTS gwf_environments (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(project_id,slug))`, `CREATE TABLE IF NOT EXISTS gwf_application_services (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, environment_id TEXT NOT NULL REFERENCES gwf_environments(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(environment_id,slug))`, - `CREATE TABLE IF NOT EXISTS gwf_organization_invitations (token_hash BLOB PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, email_normalized TEXT NOT NULL, invited_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER)`, + `CREATE TABLE IF NOT EXISTS gwf_organization_invitations (token_hash BLOB PRIMARY KEY, id TEXT NOT NULL UNIQUE, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, email_normalized TEXT NOT NULL, invited_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), direct_role TEXT NOT NULL DEFAULT '', team_ids_json BLOB NOT NULL DEFAULT '[]', created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER, revoked_at INTEGER)`, `CREATE INDEX IF NOT EXISTS gwf_organization_invitations_expiry ON gwf_organization_invitations(expires_at)`, `CREATE TABLE IF NOT EXISTS gwf_access_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_access_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, @@ -129,6 +129,42 @@ func (store *Store) Migrate(ctx context.Context) error { return err } } + for _, migration := range []struct { + table, column, definition string + }{ + {"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`}, + {"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`}, + {"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`}, + {"gwf_teams", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`}, + {"gwf_teams", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`}, + {"gwf_teams", "updated_at", `INTEGER NOT NULL DEFAULT 0`}, + {"gwf_organization_invitations", "id", `TEXT`}, + {"gwf_organization_invitations", "revoked_at", `INTEGER`}, + {"gwf_organization_invitations", "direct_role", `TEXT NOT NULL DEFAULT ''`}, + {"gwf_organization_invitations", "team_ids_json", `BLOB NOT NULL DEFAULT '[]'`}, + } { + exists, columnErr := sqliteColumnExists(ctx, tx, migration.table, migration.column) + if columnErr != nil { + return columnErr + } + if !exists { + if _, err = tx.ExecContext(ctx, `ALTER TABLE `+migration.table+` ADD COLUMN `+migration.column+` `+migration.definition); err != nil { + return err + } + } + } + if _, err = tx.ExecContext(ctx, `UPDATE gwf_organizations SET updated_at=created_at WHERE updated_at=0`); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE gwf_teams SET updated_at=created_at WHERE updated_at=0`); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET id=lower(hex(token_hash)) WHERE id IS NULL`); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS gwf_organization_invitations_id ON gwf_organization_invitations(id)`); err != nil { + return err + } if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil { return err } @@ -141,6 +177,9 @@ func (store *Store) Migrate(ctx context.Context) error { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(4,?)`, time.Now().UTC().Unix()); err != nil { return err } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(5,?)`, time.Now().UTC().Unix()); err != nil { + return err + } return tx.Commit() } diff --git a/authsqlite/store_test.go b/authsqlite/store_test.go index 907a201..e9c8e14 100644 --- a/authsqlite/store_test.go +++ b/authsqlite/store_test.go @@ -319,11 +319,11 @@ func TestOrganizationTeamResourceAndScopedAccessRoundTrip(t *testing.T) { if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil { t.Fatal(err) } - team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators"}) + team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID}) if err != nil { t.Fatal(err) } - if err = organizationService.AddTeamMember(t.Context(), team.ID, member.ID); err != nil { + if err = organizationService.AddTeamMember(t.Context(), team.ID, member.ID, owner.ID); err != nil { t.Fatal(err) } project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "eql", Name: "EQL"}) @@ -366,11 +366,96 @@ func TestOrganizationTeamResourceAndScopedAccessRoundTrip(t *testing.T) { t.Fatalf("break-glass decision=%+v err=%v", decision, err) } var audits int - if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&audits); err != nil || audits != 1 { + if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&audits); err != nil || audits != 6 { t.Fatalf("audits=%d err=%v", audits, err) } auditEvents, err := accessService.Audit(t.Context(), organization.ID, 10) - if err != nil || len(auditEvents) != 1 || auditEvents[0].Action != "break_glass.activate" { + if err != nil || len(auditEvents) != 6 { t.Fatalf("audit events=%+v err=%v", auditEvents, err) } + foundBreakGlass := false + for _, event := range auditEvents { + foundBreakGlass = foundBreakGlass || event.Action == "break_glass.activate" + } + if !foundBreakGlass { + t.Fatalf("break-glass audit missing: %+v", auditEvents) + } +} + +func TestInvitationAccessLifecycleAndLastOwnerProtection(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "owner.lifecycle", Email: "owner-lifecycle@example.test", DisplayName: "Owner", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "member.lifecycle", Email: "member-lifecycle@example.test", DisplayName: "Member", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "organization.owner"}) + if err != nil { + t.Fatal(err) + } + organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "lifecycle-test", Name: "Lifecycle Test", OwnerUserID: owner.ID}) + if err != nil { + t.Fatal(err) + } + policy := access.Policy{Roles: map[string]string{"organization.owner": "Owner"}, Permissions: map[string]string{"telemetry.read": "Read"}, Grants: map[string][]string{"organization.owner": {"telemetry.read"}}} + accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + if err = accessService.Seed(t.Context()); err != nil { + t.Fatal(err) + } + if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "organization.owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil { + t.Fatal(err) + } + if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", owner.ID, "request-last-owner"); !errors.Is(err, organizations.ErrLastOwner) { + t.Fatalf("last-owner suspension err=%v", err) + } + team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators", ActorUserID: owner.ID}) + if err != nil { + t.Fatal(err) + } + raw, invitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: member.Email, InvitedByUserID: owner.ID, DirectRole: "organization.owner", TeamIDs: []string{team.ID}, Lifetime: 7 * 24 * time.Hour}) + if err != nil { + t.Fatal(err) + } + if invitation.DirectRole != "organization.owner" || len(invitation.TeamIDs) != 1 { + t.Fatalf("invitation=%+v", invitation) + } + if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil { + t.Fatal(err) + } + decision, err := accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read") + if err != nil || !decision.Allowed { + t.Fatalf("member decision=%+v err=%v", decision, err) + } + teams, err := organizationService.Teams(t.Context(), organization.ID, member.ID) + if err != nil || len(teams) != 1 || teams[0].ID != team.ID { + t.Fatalf("member teams=%+v err=%v", teams, err) + } + if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", owner.ID, "request-suspend-owner"); err != nil { + t.Fatal(err) + } + if err = organizationService.RemoveMembership(t.Context(), organization.ID, member.ID, member.ID, "request-last-member"); !errors.Is(err, organizations.ErrLastOwner) { + t.Fatalf("sole active owner removal err=%v", err) + } + if _, err = organizationService.SetOrganizationStatus(t.Context(), organizations.SetOrganizationStatus{ID: organization.ID, Status: "archived", ActorUserID: member.ID, ExpectedRevision: organization.Revision, RequestID: "request-archive"}); err != nil { + t.Fatal(err) + } + decision, err = accessService.Authorize(t.Context(), member.ID, access.Scope{OrganizationID: organization.ID}, "telemetry.read") + if err != nil || decision.Allowed { + t.Fatalf("archived organization decision=%+v err=%v", decision, err) + } } diff --git a/authwebauthn/service.go b/authwebauthn/service.go index 38eabcb..f76ac12 100644 --- a/authwebauthn/service.go +++ b/authwebauthn/service.go @@ -190,7 +190,7 @@ func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, la if err != nil { return BeginResult{}, err } - return service.beginRegistration(ctx, user, label) + return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}) } func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) { @@ -198,10 +198,28 @@ func (service *Service) BeginRegistration(ctx context.Context, userID, label str if err != nil { return BeginResult{}, err } - return service.beginRegistration(ctx, user, label) + return service.beginRegistration(ctx, user, label, CeremonyRegistration, [32]byte{}) } -func (service *Service) beginRegistration(ctx context.Context, user auth.User, label string) (BeginResult, error) { +// BeginPasswordMigration starts registration for an already authenticated +// password-backed user. Completion atomically retires the password and revokes +// all sessions, including the session that authorized this ceremony. +func (service *Service) BeginPasswordMigration(ctx context.Context, userID, label string) (BeginResult, error) { + user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID)) + if err != nil { + return BeginResult{}, err + } + exists, err := service.repository.PasswordCredentialExists(ctx, user.ID) + if err != nil { + return BeginResult{}, err + } + if !exists { + return BeginResult{}, ErrPasswordNotAvailable + } + return service.beginRegistration(ctx, user, label, CeremonyRegistration, passwordMigrationBinding(user.ID)) +} + +func (service *Service) beginRegistration(ctx context.Context, user auth.User, label, kind string, binding [32]byte) (BeginResult, error) { label, err := credentialLabel(label) if err != nil { return BeginResult{}, err @@ -223,14 +241,35 @@ func (service *Service) beginRegistration(ctx context.Context, user auth.User, l if err != nil { return BeginResult{}, fmt.Errorf("authwebauthn: begin registration: %w", err) } - return service.storeCeremony(ctx, CeremonyRegistration, user.ID, label, session, [32]byte{}, creation.Response, service.config.RegistrationTTL) + return service.storeCeremony(ctx, kind, user.ID, label, session, binding, creation.Response, service.config.RegistrationTTL) } func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) { + return service.finishRegistration(ctx, ceremonyToken, CeremonyRegistration, [32]byte{}, response, false) +} + +// FinishPasswordMigration verifies the new passkey and persists it together +// with password retirement and session revocation in one storage transaction. +func (service *Service) FinishPasswordMigration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) { ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyRegistration) if err != nil { return Credential{}, err } + return service.finishRegistrationCeremony(ctx, ceremony, passwordMigrationBinding(ceremony.UserID), response, true) +} + +func (service *Service) finishRegistration(ctx context.Context, ceremonyToken, kind string, expectedBinding [32]byte, response []byte, retirePassword bool) (Credential, error) { + ceremony, err := service.takeCeremony(ctx, ceremonyToken, kind) + if err != nil { + return Credential{}, err + } + return service.finishRegistrationCeremony(ctx, ceremony, expectedBinding, response, retirePassword) +} + +func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony Ceremony, expectedBinding [32]byte, response []byte, retirePassword bool) (Credential, error) { + if ceremony.BindingDigest != expectedBinding { + return Credential{}, ErrOperationBinding + } if len(response) == 0 || len(response) > maxResponseBytes { return Credential{}, errors.New("authwebauthn: registration response is invalid") } @@ -263,11 +302,20 @@ func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken st } now := service.now().UTC() record := Credential{ID: append([]byte(nil), verified.ID...), UserID: user.ID, Label: ceremony.Label, Data: encoded, CreatedAt: now} - audit, err := service.audit(user.ID, "auth.passkey.add", "passkey", base64.RawURLEncoding.EncodeToString(verified.ID), "A passkey was enrolled.") + action, summary := "auth.passkey.add", "A passkey was enrolled." + if retirePassword { + action, summary = "auth.passkey.migrate", "A passkey was enrolled and the legacy password credential was retired." + } + audit, err := service.audit(user.ID, action, "passkey", base64.RawURLEncoding.EncodeToString(verified.ID), summary) if err != nil { return Credential{}, err } - if err = service.repository.SaveCredential(ctx, record, audit); err != nil { + if retirePassword { + err = service.repository.SaveCredentialAndRetirePassword(ctx, record, audit) + } else { + err = service.repository.SaveCredential(ctx, record, audit) + } + if err != nil { return Credential{}, err } return record, nil @@ -594,6 +642,10 @@ func credentialRemovalBinding(userID string, credentialID []byte) []byte { return []byte("gamertan-web/passkey-remove/v1\x00" + userID + "\x00" + base64.RawURLEncoding.EncodeToString(credentialID)) } +func passwordMigrationBinding(userID string) [32]byte { + return BindingDigest([]byte("gamertan-web/password-to-passkey/v1\x00" + userID)) +} + func validateOrigin(rpID, rawOrigin string) error { if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" { return errors.New("authwebauthn: relying-party ID and origin are required") diff --git a/authwebauthn/service_test.go b/authwebauthn/service_test.go index 9f48715..6a3afa0 100644 --- a/authwebauthn/service_test.go +++ b/authwebauthn/service_test.go @@ -4,6 +4,7 @@ package authwebauthn_test import ( "bytes" + "crypto/sha256" "encoding/json" "errors" "io" @@ -140,6 +141,51 @@ func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) { } } +func TestPasswordMigrationCeremonyIsBoundAndUnavailableAfterRetirement(t *testing.T) { + now := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC) + store, err := authsqlite.Open(t.TempDir() + "/auth.db") + if err != nil { + t.Fatal(err) + } + defer store.Close() + authService, err := auth.New(store, auth.Options{Random: &counterReader{}, Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + user, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "legacy.user", Email: "legacy@example.test", DisplayName: "Legacy User", Password: "legacy migration password"}) + if err != nil { + t.Fatal(err) + } + service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "observatory.test", RPDisplayName: "Observatory", Origin: "https://observatory.test", RequiredCredentialCount: 1, Random: &counterReader{}, Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + begin, err := service.BeginPasswordMigration(t.Context(), user.ID, "Primary passkey") + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256([]byte(begin.CeremonyToken)) + ceremony, err := store.TakeCeremony(t.Context(), digest, now) + if err != nil { + t.Fatal(err) + } + if ceremony.Kind != authwebauthn.CeremonyRegistration || ceremony.BindingDigest == ([32]byte{}) || ceremony.UserID != user.ID { + t.Fatalf("unexpected migration ceremony: %+v", ceremony) + } + credential := wa.Credential{ID: bytes.Repeat([]byte{9}, 32), PublicKey: []byte{1, 2, 3}} + encoded, err := json.Marshal(credential) + if err != nil { + t.Fatal(err) + } + audit := auth.AuditEvent{ID: "migration-direct", ActorUserID: user.ID, Action: "auth.passkey.migrate", ResourceType: "passkey", ResourceID: "credential", Summary: "migration", CreatedAt: now} + if err = store.SaveCredentialAndRetirePassword(t.Context(), authwebauthn.Credential{ID: credential.ID, UserID: user.ID, Label: "Primary", Data: encoded, CreatedAt: now}, audit); err != nil { + t.Fatal(err) + } + if _, err = service.BeginPasswordMigration(t.Context(), user.ID, "Replay"); !errors.Is(err, authwebauthn.ErrPasswordNotAvailable) { + t.Fatalf("retired password migration err=%v", err) + } +} + func TestConfigurationAndEntropyFailures(t *testing.T) { store, err := authsqlite.Open(t.TempDir() + "/auth.db") if err != nil { diff --git a/authwebauthn/types.go b/authwebauthn/types.go index 69ccea8..fd1800f 100644 --- a/authwebauthn/types.go +++ b/authwebauthn/types.go @@ -25,6 +25,7 @@ var ( ErrOperationBinding = errors.New("authwebauthn: operation binding does not match") ErrPasskeyReadiness = errors.New("authwebauthn: at least two passkeys are required") ErrUnsupportedCredential = errors.New("authwebauthn: credential algorithm is unsupported") + ErrPasswordNotAvailable = errors.New("authwebauthn: password migration is not available") ) const ( @@ -100,7 +101,9 @@ type Repository interface { UserByIdentifier(context.Context, string) (auth.User, error) UserByCredentialID(context.Context, []byte) (auth.User, error) CredentialsByUserID(context.Context, string) ([]Credential, error) + PasswordCredentialExists(context.Context, string) (bool, error) SaveCredential(context.Context, Credential, auth.AuditEvent) error + SaveCredentialAndRetirePassword(context.Context, Credential, auth.AuditEvent) error UpdateCredential(context.Context, Credential) error DeleteCredential(context.Context, string, []byte, int, auth.AuditEvent) error CredentialCount(context.Context, string) (int, error) diff --git a/docs/ORGANIZATIONS.md b/docs/ORGANIZATIONS.md index 91552c0..c17c659 100644 --- a/docs/ORGANIZATIONS.md +++ b/docs/ORGANIZATIONS.md @@ -8,10 +8,19 @@ environments; environments own application services. Teams are optional groups of active organization members. `organizations.Service` creates those resources and issues digest-backed, -expiring, single-use invitations. Acceptance verifies that the authenticated -user's normalized email matches the invitation before activating membership. -Applications own invitation pages, email or out-of-band delivery, organization -deletion policy, and account recovery. +expiring, single-use invitations. An invitation may carry one direct role and +up to sixteen reviewed team memberships. Acceptance verifies that the +authenticated user's normalized email matches and applies the membership, +role, teams, consumption marker, and audit event in one transaction. +Applications own invitation pages, email or out-of-band delivery, active-source +checks before archival, and account recovery. + +Organizations and teams use optimistic revisions and reversible +`active`/`archived` states. Archived objects keep their history but contribute +no effective authority. Memberships may be suspended, reactivated, or removed; +team membership can be removed independently. Configure `OwnerRole` when +constructing the service before exposing membership-removal operations. The +SQLite adapter then refuses to suspend or remove the final active direct owner. `access.Service` evaluates a permission against a complete resource scope: @@ -34,8 +43,8 @@ grant organization-data access. If an operator must inspect tenant data during an incident, use a reasoned break-glass grant. It expires within one hour and creates an append-only audit event in the same transaction. -The SQLite adapter namespaces all tables, enforces organization membership and -resource ancestry before accepting a binding, and keeps invitations and -sessions as digests. Applications remain responsible for database backup, -filesystem ownership, retention, and presenting audit history to organization -owners. +The SQLite adapter namespaces all tables, enforces active organization and team +membership plus resource ancestry before accepting or evaluating a binding, +and keeps invitations and sessions as digests. Applications remain responsible +for database backup, filesystem ownership, retention, and presenting audit +history to organization owners. diff --git a/docs/PASSKEYS.md b/docs/PASSKEYS.md index 8aa4a3f..20d3219 100644 --- a/docs/PASSKEYS.md +++ b/docs/PASSKEYS.md @@ -37,6 +37,14 @@ timestamp, UUID, or counter for the random challenge. 5. Sensitive operations call `BeginApproval` with a canonical application payload and `FinishApproval` with those exact same bytes. Any drift fails. +For an existing password-backed account, call `BeginPasswordMigration` only +from an authenticated account session and finish with +`FinishPasswordMigration`. The registration ceremony is bound to that user. +Successful completion stores the passkey, removes the password credential, +clears the password-change flag, revokes every session and pending ceremony, +and appends the audit event atomically. The application must clear the current +session cookie and return the user to passkey login after success. + `authhttp.WritePasskeyBegin` and `authhttp.ReadPasskeyFinish` provide bounded JSON framing only. They do not register routes, authorize requests, serve JavaScript, or set sessions automatically. diff --git a/organizations/organizations.go b/organizations/organizations.go index 44127ea..4a29bcd 100644 --- a/organizations/organizations.go +++ b/organizations/organizations.go @@ -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}$`) diff --git a/organizations/organizations_test.go b/organizations/organizations_test.go index 66a7ea1..016ae88 100644 --- a/organizations/organizations_test.go +++ b/organizations/organizations_test.go @@ -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 }