This commit is contained in:
+13
-5
@@ -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
|
||||
}
|
||||
|
||||
+395
-26
@@ -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
|
||||
|
||||
+64
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
+42
-3
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user