Support atomic organization role sets and owner-managed invitations
verify / verify (push) Successful in 4m17s

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
2026-09-05 00:10:08 -04:00
parent ed0cc8ceff
commit c0986168bc
19 changed files with 1052 additions and 69 deletions
+37 -15
View File
@@ -167,9 +167,28 @@ func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID
}
func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) error {
if !validOrganizationRoleReplacement(expected, replacement, ownerRole, audit) {
return errors.New("authsqlite: invalid organization role replacement")
return store.replaceOrganizationUserRoles(ctx, expected, []access.Binding{replacement}, ownerRole, audit, false)
}
func (store *Store) ReplaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent) error {
return store.replaceOrganizationUserRoles(ctx, expected, replacements, ownerRole, audit, true)
}
func (store *Store) replaceOrganizationUserRoles(ctx context.Context, expected []string, replacements []access.Binding, ownerRole string, audit access.AuditEvent, requireOwner bool) error {
if len(replacements) < 1 || len(replacements) > 16 {
return errors.New("authsqlite: invalid organization role set")
}
replacement := replacements[0]
roles := make([]string, 0, len(replacements))
ids := make(map[string]bool, len(replacements))
for _, value := range replacements {
if !validOrganizationRoleReplacement(expected, value, ownerRole, audit) || value.SubjectID != replacement.SubjectID || value.Scope != replacement.Scope || value.GrantedBy != replacement.GrantedBy || !value.GrantedAt.Equal(replacement.GrantedAt) || ids[value.ID] || slices.Contains(roles, value.Role) {
return errors.New("authsqlite: invalid organization role set")
}
roles = append(roles, value.Role)
ids[value.ID] = true
}
slices.Sort(roles)
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
@@ -183,7 +202,7 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active'
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, replacement.Scope.OrganizationID, replacement.GrantedBy, replacement.Scope.OrganizationID, replacement.GrantedBy)
if err != nil {
return err
}
@@ -195,7 +214,7 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
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'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
return err
}
@@ -231,10 +250,11 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
if !slices.Equal(currentIDs, expected) {
return access.ErrRoleChangeConflict
}
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
slices.Sort(currentRoles)
if slices.Equal(currentRoles, roles) {
return access.ErrRoleUnchanged
}
if replacement.Role == ownerRole || slices.Contains(currentRoles, ownerRole) {
if requireOwner || slices.Contains(roles, ownerRole) || slices.Contains(currentRoles, ownerRole) {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole)
if ownerErr != nil {
return ownerErr
@@ -243,12 +263,12 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
return access.ErrOwnerAuthority
}
}
if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) {
if !slices.Contains(roles, ownerRole) && slices.Contains(currentRoles, ownerRole) {
var otherOwners 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'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? 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`, replacement.Scope.OrganizationID, replacement.SubjectID, ownerRole).Scan(&otherOwners); err != nil {
@@ -265,13 +285,15 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected []
AND revoked_at IS NULL`, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Scope.OrganizationID, replacement.SubjectID); err != nil {
return err
}
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at)
SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, replacement.ID, replacement.Scope.OrganizationID, replacement.SubjectID, replacement.Role, replacement.GrantedBy, replacement.GrantedAt.Unix(), replacement.Role)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: replacement role has not been seeded")
for _, value := range replacements {
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=?`, value.ID, value.Scope.OrganizationID, value.SubjectID, value.Role, value.GrantedBy, value.GrantedAt.Unix(), value.Role)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: replacement role has not been seeded")
}
}
if err = appendAccessAudit(ctx, tx, audit); err != nil {
return err
+109 -27
View File
@@ -7,6 +7,8 @@ import (
"database/sql"
"encoding/json"
"errors"
"slices"
"strconv"
"time"
"gamertan.com/web/organizations"
@@ -123,10 +125,28 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
return nil
}
func (store *Store) CreateInvitationWithRoles(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
return store.CreateInvitation(ctx, invitation, ownerRole, audit)
}
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, 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) || ownerRole != "" && !safeName(ownerRole) || !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")
}
roles, err := invitation.RoleNames()
if err != nil {
return err
}
if invitation.RequiredOwnerRole != "" && invitation.RequiredOwnerRole != ownerRole || audit.ActorUserID != invitation.InvitedByUserID || audit.Action != "invitation.create" || audit.ResourceType != "invitation" || audit.ResourceID != invitation.ID {
return errors.New("authsqlite: invalid invitation authority")
}
if ownerRole != "" && slices.Contains(roles, ownerRole) {
invitation.RequiredOwnerRole = ownerRole
}
rolesJSON, err := json.Marshal(invitation.DirectRoles)
if err != nil {
return err
}
teamIDs, err := json.Marshal(invitation.TeamIDs)
if err != nil {
return err
@@ -139,8 +159,8 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
return err
}
if ownerRole != "" && invitation.DirectRole == ownerRole {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, ownerRole)
if invitation.RequiredOwnerRole != "" {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, invitation.RequiredOwnerRole)
if ownerErr != nil {
return ownerErr
}
@@ -151,9 +171,18 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
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)
for _, role := range roles {
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_roles WHERE name=?`, role).Scan(&count); err != nil {
return err
}
if count != 1 {
return errors.New("authsqlite: invitation role has not been seeded")
}
}
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,direct_roles_json,required_owner_role)
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, rolesJSON, invitation.RequiredOwnerRole, invitation.OrganizationID, invitation.InvitedByUserID)
if err != nil {
return err
}
@@ -172,8 +201,8 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
}
var invitation organizations.Invitation
var created, expires int64
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)
var teamIDs, rolesJSON []byte
err := store.db.QueryRowContext(ctx, `SELECT id,organization_id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,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, &rolesJSON, &invitation.RequiredOwnerRole, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
@@ -184,13 +213,20 @@ func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now
if err = json.Unmarshal(teamIDs, &invitation.TeamIDs); err != nil || !validInvitationTeamIDs(invitation.TeamIDs) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
if !decodeInvitationRoles(&invitation, rolesJSON) {
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, audit organizations.AuditEvent) error {
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) {
return store.AcceptInvitationWithRoles(ctx, digest, userID, "", acceptedAt, audit)
}
func (store *Store) AcceptInvitationWithRoles(ctx context.Context, digest [32]byte, userID, ownerRole string, acceptedAt time.Time, audit organizations.AuditEvent) error {
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() || !validOrganizationAudit(audit, audit.OrganizationID) || audit.ActorUserID != userID || ownerRole != "" && !safeName(ownerRole) {
return organizations.ErrInvitationNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
@@ -198,15 +234,44 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
return err
}
defer tx.Rollback()
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)
// Serialize acceptance before reading token state, including competing users.
if _, err = tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET expires_at=expires_at WHERE token_hash=?`, digest[:]); err != nil {
return err
}
var invitationID, organizationID, directRole, invitedBy, requiredOwnerRole string
var teamIDsJSON, rolesJSON []byte
err = tx.QueryRowContext(ctx, `SELECT i.id,i.organization_id,i.direct_role,i.team_ids_json,i.invited_by_user_id,i.direct_roles_json,i.required_owner_role FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized AND u.status='active' AND u.registration_pending=0 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>? AND NOT EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=i.organization_id AND m.user_id=u.id AND m.status<>'active')`, userID, digest[:], acceptedAt.Unix()).Scan(&invitationID, &organizationID, &directRole, &teamIDsJSON, &invitedBy, &rolesJSON, &requiredOwnerRole)
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
if err != nil {
return err
}
invitation := organizations.Invitation{DirectRole: directRole, RequiredOwnerRole: requiredOwnerRole}
if !decodeInvitationRoles(&invitation, rolesJSON) {
return organizations.ErrInvitationNotFound
}
roles, _ := invitation.RoleNames()
if audit.OrganizationID != organizationID || audit.ResourceType != "invitation" || audit.ResourceID != invitationID || audit.Action != "invitation.accept" {
return organizations.ErrInvitationNotFound
}
// Stored authority survives which application service receives the link.
// The caller's owner role also protects pre-schema-10 single-role invitations.
if requiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) {
requiredOwnerRole = ownerRole
}
if err = lockActiveMembershipActor(ctx, tx, organizationID, invitedBy); err != nil {
return err
}
if requiredOwnerRole != "" {
isOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, invitedBy, requiredOwnerRole)
if ownerErr != nil {
return ownerErr
}
if !isOwner {
return organizations.ErrOwnerAuthority
}
}
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
}
@@ -222,11 +287,12 @@ func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userI
return err
}
}
if directRole != "" {
if !safeName(directRole) {
return organizations.ErrInvitationNotFound
for i, role := range roles {
bindingID := "invite-" + invitationID
if len(invitation.DirectRoles) > 0 {
bindingID += "-" + strconv.Itoa(i)
}
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)
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=?`, bindingID, organizationID, userID, role, invitedBy, acceptedAt.Unix(), role)
if err != nil {
return err
}
@@ -241,9 +307,6 @@ 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
}
@@ -603,7 +666,7 @@ func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID,
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_memberships SET status=status
WHERE organization_id=? AND user_id=? AND status='active'
AND EXISTS (SELECT 1 FROM gwf_organizations o WHERE o.id=? AND o.status='active')
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active')`, organizationID, actorUserID, organizationID, actorUserID)
AND EXISTS (SELECT 1 FROM gwf_users u WHERE u.id=? AND u.status='active' AND u.registration_pending=0)`, organizationID, actorUserID, organizationID, actorUserID)
if err != nil {
return err
}
@@ -665,7 +728,7 @@ func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, o
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'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active'
JOIN gwf_users u ON u.id=m.user_id AND u.status='active' AND u.registration_pending=0
WHERE b.organization_id=? AND b.subject_kind='user' AND b.subject_id<>? 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, userID, ownerRole).Scan(&otherActiveOwners); err != nil {
@@ -696,7 +759,7 @@ func (store *Store) Invitations(ctx context.Context, organizationID string, limi
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)
rows, err := store.db.QueryContext(ctx, `SELECT id,email_normalized,invited_by_user_id,direct_role,team_ids_json,direct_roles_json,required_owner_role,created_at,expires_at,COALESCE(used_at,0),COALESCE(revoked_at,0) FROM gwf_organization_invitations WHERE organization_id=? ORDER BY created_at DESC,id LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
@@ -705,13 +768,16 @@ func (store *Store) Invitations(ctx context.Context, organizationID string, limi
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 {
var teamIDs, rolesJSON []byte
if err = rows.Scan(&value.ID, &value.Email, &value.InvitedByUserID, &value.DirectRole, &teamIDs, &rolesJSON, &value.RequiredOwnerRole, &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")
}
if !decodeInvitationRoles(&value, rolesJSON) {
return nil, errors.New("authsqlite: stored invitation roles are invalid")
}
value.OrganizationID = organizationID
value.CreatedAt, value.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if used != 0 {
@@ -742,6 +808,14 @@ func validInvitationTeamIDs(teamIDs []string) bool {
return true
}
func decodeInvitationRoles(invitation *organizations.Invitation, raw []byte) bool {
if len(raw) > 4096 || json.Unmarshal(raw, &invitation.DirectRoles) != nil || invitation.RequiredOwnerRole != "" && !safeName(invitation.RequiredOwnerRole) {
return false
}
_, err := invitation.RoleNames()
return err == nil
}
func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID string, teamIDs []string) error {
for _, teamID := range teamIDs {
var count int
@@ -767,15 +841,23 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
var directRole string
if err = tx.QueryRowContext(ctx, `SELECT direct_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&directRole); err != nil {
var invitation organizations.Invitation
var rolesJSON []byte
if err = tx.QueryRowContext(ctx, `SELECT direct_role,direct_roles_json,required_owner_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&invitation.DirectRole, &rolesJSON, &invitation.RequiredOwnerRole); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
return err
}
if ownerRole != "" && directRole == ownerRole {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, ownerRole)
if !decodeInvitationRoles(&invitation, rolesJSON) {
return organizations.ErrInvitationNotFound
}
roles, _ := invitation.RoleNames()
if invitation.RequiredOwnerRole == "" && ownerRole != "" && slices.Contains(roles, ownerRole) {
invitation.RequiredOwnerRole = ownerRole
}
if invitation.RequiredOwnerRole != "" {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, invitation.RequiredOwnerRole)
if ownerErr != nil {
return ownerErr
}
+438
View File
@@ -0,0 +1,438 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"errors"
"slices"
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/organizations"
)
type roleSetFixture struct {
store *Store
access *access.Service
organizations *organizations.Service
org organizations.Organization
now time.Time
}
const roleOwner = "customer-12345"
const roleMember = "member-12345678"
func newRoleSetFixture(t *testing.T) roleSetFixture {
t.Helper()
store, _, policy, input := ownedOrganizationFixture(t)
policy.Roles["buyer"] = "Buyer"
policy.Roles["billing"] = "Billing manager"
policy.Roles["member"] = "Member"
policy.Permissions["billing.manage"] = "Manage billing"
policy.Grants["buyer"] = []string{"customer.purchase"}
policy.Grants["billing"] = []string{"billing.manage"}
policy.Grants["member"] = nil
now := time.Unix(2100, 0).UTC()
accessService, err := access.New(store, policy, access.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true, Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
org, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at)
VALUES(?,?,?,?,?,?,'active',0,2000,2000)`, roleMember, "member", "member", "member@example.test", "member@example.test", "Member"); err != nil {
t.Fatal(err)
}
return roleSetFixture{store: store, access: accessService, organizations: service, org: org, now: now}
}
func (f roleSetFixture) invite(t *testing.T, roles ...string) (string, organizations.Invitation) {
t.Helper()
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRoles: roles, Lifetime: time.Hour, RequestID: "request-invite"})
if err != nil {
t.Fatal(err)
}
return raw, invitation
}
func (f roleSetFixture) addMember(t *testing.T) {
t.Helper()
raw, _ := f.invite(t, "member")
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatal(err)
}
}
func (f roleSetFixture) bindings(t *testing.T, user string) ([]string, []string) {
t.Helper()
bindings, err := f.access.OrganizationUserBindings(t.Context(), f.org.ID, 100)
if err != nil {
t.Fatal(err)
}
var ids, roles []string
for _, binding := range bindings {
if binding.SubjectID == user {
ids = append(ids, binding.ID)
roles = append(roles, binding.Role)
}
}
slices.Sort(ids)
slices.Sort(roles)
return ids, roles
}
func (f roleSetFixture) change(t *testing.T, actor, target string, roles ...string) error {
t.Helper()
ids, _ := f.bindings(t, target)
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, RequestID: "request-roles", Roles: roles, ExpectedBindingIDs: ids})
return err
}
func TestRoleSetCombinesCapabilitiesWithoutNarrowGrantChanges(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
project, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"})
if err != nil {
t.Fatal(err)
}
narrow, err := f.access.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: roleMember, Role: "member", Scope: access.Scope{OrganizationID: f.org.ID, ProjectID: project.ID}, GrantedBy: roleOwner})
if err != nil {
t.Fatal(err)
}
if err = f.change(t, roleOwner, roleMember, "buyer", "billing"); err != nil {
t.Fatal(err)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
for _, permission := range []string{"customer.purchase", "billing.manage"} {
decision, err := f.access.Authorize(t.Context(), roleMember, access.Scope{OrganizationID: f.org.ID}, permission)
if err != nil || !decision.Allowed {
t.Fatalf("permission=%s allowed=%v err=%v", permission, decision.Allowed, err)
}
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=? AND revoked_at IS NULL`, narrow.ID, 1)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
if err = f.change(t, roleOwner, roleMember, "billing", "buyer"); !errors.Is(err, access.ErrRoleUnchanged) {
t.Fatalf("unchanged=%v", err)
}
if err = f.change(t, roleMember, roleMember, "member"); !errors.Is(err, access.ErrOwnerAuthority) {
t.Fatalf("non-owner bulk change=%v", err)
}
if err = f.change(t, roleOwner, roleOwner, "billing", "buyer"); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("last owner=%v", err)
}
if err = f.change(t, roleOwner, roleMember, "customer.owner", "billing"); err != nil {
t.Fatal(err)
}
if err = f.change(t, roleMember, roleOwner, "buyer"); err != nil {
t.Fatal(err)
}
if err = f.change(t, roleMember, roleMember, "buyer"); !errors.Is(err, access.ErrLastOwner) {
t.Fatalf("new last owner=%v", err)
}
}
func TestRoleSetConcurrentChangesHaveOneWinner(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
ids, _ := f.bindings(t, roleMember)
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() {
<-start
_, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: ids})
results <- err
}()
}
close(start)
success, conflict := 0, 0
for range 2 {
err := <-results
switch {
case err == nil:
success++
case errors.Is(err, access.ErrRoleChangeConflict):
conflict++
default:
t.Fatalf("concurrent error=%v", err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("success=%d conflict=%d", success, conflict)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1)
}
func TestRoleSetRollsBackRevocationAndPartialInsert(t *testing.T) {
for _, stage := range []string{"second binding", "audit", "missing role"} {
t.Run(stage, func(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
before, _ := f.bindings(t, roleMember)
var statement string
switch stage {
case "second binding":
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'write failure'); END`
case "audit":
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='access.role.replace' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
case "missing role":
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
}
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
if err := f.change(t, roleOwner, roleMember, "billing", "buyer"); err == nil {
t.Fatal("write failure accepted")
}
after, roles := f.bindings(t, roleMember)
if !slices.Equal(before, after) || !slices.Equal(roles, []string{"member"}) {
t.Fatalf("after=%v roles=%v", after, roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 0)
})
}
}
func TestRoleInvitationPreservesRolesAuthorityAndSingleUse(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
if err != nil {
t.Fatal(err)
}
if stored.RequiredOwnerRole != "customer.owner" || stored.DirectRole != "" || !slices.Equal(stored.DirectRoles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v authority=%q", stored.DirectRoles, stored.RequiredOwnerRole)
}
listed, err := f.organizations.Invitations(t.Context(), f.org.ID, 10)
if err != nil || len(listed) != 1 || !slices.Equal(listed[0].DirectRoles, stored.DirectRoles) {
t.Fatalf("listed=%v err=%v", listed, err)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.create' AND request_id='request-invite'`, invitation.ID, 1)
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleOwner); err == nil {
t.Fatal("wrong email accepted")
}
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() { <-start; results <- f.organizations.AcceptInvitation(t.Context(), raw, roleMember) }()
}
close(start)
success := 0
for range 2 {
if err := <-results; err == nil {
success++
} else if !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("accept error=%v", err)
}
}
if success != 1 {
t.Fatalf("accepted=%d", success)
}
_, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"billing", "buyer"}) {
t.Fatalf("roles=%v", roles)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 1)
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) {
t.Fatalf("replay=%v", err)
}
}
func TestRoleInvitationRechecksGrantorAndRecipient(t *testing.T) {
for _, mutation := range []string{
`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`,
`UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`,
`UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`,
`DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`,
`UPDATE gwf_users SET status='disabled' WHERE id='member-12345678'`,
`UPDATE gwf_users SET registration_pending=1 WHERE id='member-12345678'`,
`UPDATE gwf_organizations SET status='archived'`,
`UPDATE gwf_organization_invitations SET expires_at=2100`,
`UPDATE gwf_organization_invitations SET revoked_at=2100`,
`UPDATE gwf_organization_invitations SET direct_roles_json='["buyer","buyer"]'`,
} {
t.Run(mutation, func(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
if _, err := f.store.db.Exec(mutation); err != nil {
t.Fatal(err)
}
// Stored authority still applies through a differently configured service.
other, err := organizations.New(f.store, organizations.Options{Now: func() time.Time { return f.now }})
if err != nil {
t.Fatal(err)
}
if err = other.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("stale or invalid authority accepted")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
})
}
}
func TestRoleInvitationRejectsImplicitReactivationAndNonOwnerManagement(t *testing.T) {
f := newRoleSetFixture(t)
f.addMember(t)
if _, _, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "new@example.test", InvitedByUserID: roleMember, DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("member invite=%v", err)
}
raw, invitation := f.invite(t, "buyer", "billing")
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleMember, "request-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("member revoke=%v", err)
}
if err := f.organizations.SetMembershipStatus(t.Context(), f.org.ID, roleMember, "suspended", roleOwner, "request-suspend"); err != nil {
t.Fatal(err)
}
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("invitation reactivated suspended member")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='suspended'`, roleMember, 1)
if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleOwner, "request-revoke-owner"); err != nil {
t.Fatal(err)
}
}
func TestRoleInvitationAcceptanceRollsBackEveryWrite(t *testing.T) {
for _, stage := range []string{"membership", "binding", "audit", "missing role"} {
t.Run(stage, func(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation := f.invite(t, "buyer", "billing")
var statement string
switch stage {
case "membership":
statement = `CREATE TRIGGER fail_member BEFORE INSERT ON gwf_organization_memberships BEGIN SELECT RAISE(ABORT,'membership failure'); END`
case "binding":
statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'binding failure'); END`
case "audit":
statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.accept' BEGIN SELECT RAISE(ABORT,'audit failure'); END`
case "missing role":
statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'`
}
if _, err := f.store.db.Exec(statement); err != nil {
t.Fatal(err)
}
if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil {
t.Fatal("partial acceptance succeeded")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0)
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 0)
})
}
}
func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *testing.T) {
f := newRoleSetFixture(t)
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRole: "customer.owner", Lifetime: time.Hour})
if err != nil {
t.Fatal(err)
}
// Reconstruct the previous invitation schema in this disposable database.
if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json;
ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role;
DELETE FROM gamertan_web_migrations WHERE version=10`); err != nil {
t.Fatal(err)
}
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
t.Fatal("old schema accepted without migration")
}
for range 2 {
if err = f.store.Migrate(t.Context()); err != nil {
t.Fatal(err)
}
}
if err = f.store.RequireCurrentSchema(t.Context()); err != nil {
t.Fatal(err)
}
stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now)
if err != nil || stored.DirectRole != "customer.owner" || len(stored.DirectRoles) != 0 || stored.RequiredOwnerRole != "" {
t.Fatalf("legacy changed: %+v err=%v", stored, err)
}
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id=?`, roleOwner); err != nil {
t.Fatal(err)
}
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("legacy owner authority=%v", err)
}
if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=NULL WHERE subject_id=?`, roleOwner); err != nil {
t.Fatal(err)
}
if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatal(err)
}
ids, roles := f.bindings(t, roleMember)
if !slices.Equal(roles, []string{"customer.owner"}) || !slices.Equal(ids, []string{"invite-" + invitation.ID}) {
t.Fatalf("legacy IDs=%v roles=%v", ids, roles)
}
}
func TestRoleInvitationUsesAdvancingClockAndBoundAudit(t *testing.T) {
f := newRoleSetFixture(t)
service, err := organizations.New(f.store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true})
if err != nil {
t.Fatal(err)
}
raw, invitation, err := service.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: []string{"billing", "buyer"}, Lifetime: time.Hour})
if err != nil {
t.Fatal(err)
}
audit := organizations.AuditEvent{ID: "audit-accept-12345", OrganizationID: f.org.ID, ActorUserID: roleMember, Action: "invitation.accept", ResourceType: "invitation", ResourceID: invitation.ID, Summary: "Invitation accepted", CreatedAt: time.Now().UTC()}
for _, field := range []string{"actor", "resource", "action"} {
bad := audit
switch field {
case "actor":
bad.ActorUserID = roleOwner
case "resource":
bad.ResourceID = "invitation-other"
case "action":
bad.Action = "invitation.create"
}
if err = f.store.AcceptInvitationWithRoles(t.Context(), invitation.Digest, roleMember, "customer.owner", time.Now().UTC(), bad); err == nil {
t.Fatalf("mismatched audit %s accepted", field)
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0)
}
if err = service.AcceptInvitation(t.Context(), raw, roleMember); err != nil {
t.Fatalf("real clock acceptance=%v", err)
}
}
func TestRoleInvitationCreationIsAtomicAndRejectsUnknownRole(t *testing.T) {
for _, fail := range []string{"unknown role", "audit"} {
t.Run(fail, func(t *testing.T) {
f := newRoleSetFixture(t)
roles := []string{"billing", "buyer"}
if fail == "unknown role" {
roles = append(roles, "unknown")
} else if _, err := f.store.db.Exec(`CREATE TRIGGER fail_invite BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.create' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil {
t.Fatal(err)
}
raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: roles, Lifetime: time.Hour})
if err == nil || raw != "" || invitation.ID != "" {
t.Fatal("failed creation returned invitation")
}
assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE organization_id=?`, f.org.ID, 0)
})
}
}
+6 -1
View File
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
return store, nil
}
const SchemaVersion = 9
const SchemaVersion = 10
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
var exists int
@@ -189,6 +189,8 @@ func (store *Store) Migrate(ctx context.Context) error {
{"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 '[]'`},
{"gwf_organization_invitations", "direct_roles_json", `BLOB NOT NULL DEFAULT '[]'`},
{"gwf_organization_invitations", "required_owner_role", `TEXT NOT NULL DEFAULT ''`},
} {
exists, columnErr := sqliteColumnExists(ctx, tx, migration.table, migration.column)
if columnErr != nil {
@@ -239,6 +241,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(9,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(10,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}