Add atomic organization role administration
verify / verify (push) Successful in 3m39s

This commit is contained in:
2026-09-03 22:51:53 -04:00
parent 17bd9453e2
commit fe6bd94c9a
13 changed files with 549 additions and 12 deletions
+154
View File
@@ -6,6 +6,7 @@ import (
"context"
"database/sql"
"errors"
"slices"
"time"
"gamertan.com/web/access"
@@ -134,6 +135,159 @@ func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userI
return result, rows.Err()
}
func (store *Store) OrganizationUserBindings(ctx context.Context, organizationID string, limit int) ([]access.Binding, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 2000 {
return nil, errors.New("authsqlite: invalid organization binding query")
}
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_id,b.role_name,b.granted_by_user_id,b.granted_at
FROM gwf_access_bindings b
JOIN gwf_organization_memberships m ON m.organization_id=b.organization_id AND m.user_id=b.subject_id
WHERE b.organization_id=? AND b.subject_kind='user'
AND b.project_id IS NULL AND b.environment_id IS NULL AND b.service_id IS NULL
AND b.revoked_at IS NULL
ORDER BY b.subject_id,b.role_name,b.id
LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]access.Binding, 0)
for rows.Next() {
var binding access.Binding
var granted int64
if err = rows.Scan(&binding.ID, &binding.SubjectID, &binding.Role, &binding.GrantedBy, &granted); err != nil {
return nil, err
}
binding.SubjectKind = access.User
binding.Scope = access.Scope{OrganizationID: organizationID}
binding.GrantedAt = time.Unix(granted, 0).UTC()
result = append(result, binding)
}
return result, rows.Err()
}
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")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Acquire the SQLite write lock before reading the optimistic binding set.
// This serializes competing role replacements so the loser observes the
// committed binding IDs and returns ErrRoleChangeConflict instead of an
// ambiguous busy-snapshot error.
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)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: role grantor is not active in organization")
}
var active int
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'
WHERE m.organization_id=? AND m.user_id=? AND m.status='active'`, replacement.Scope.OrganizationID, replacement.SubjectID).Scan(&active); err != nil {
return err
}
if active != 1 {
return errors.New("authsqlite: access subject is not active in organization")
}
rows, err := tx.QueryContext(ctx, `SELECT id,role_name FROM gwf_access_bindings
WHERE organization_id=? AND subject_kind='user' AND subject_id=?
AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
AND revoked_at IS NULL ORDER BY id`, replacement.Scope.OrganizationID, replacement.SubjectID)
if err != nil {
return err
}
var currentIDs []string
var currentRoles []string
for rows.Next() {
var id, role string
if err = rows.Scan(&id, &role); err != nil {
rows.Close()
return err
}
currentIDs = append(currentIDs, id)
currentRoles = append(currentRoles, role)
}
if err = rows.Err(); err != nil {
rows.Close()
return err
}
if err = rows.Close(); err != nil {
return err
}
if !slices.Equal(currentIDs, expected) {
return access.ErrRoleChangeConflict
}
if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
return access.ErrRoleUnchanged
}
if replacement.Role != 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'
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 {
return err
}
if otherOwners == 0 {
return access.ErrLastOwner
}
}
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 project_id IS NULL AND environment_id IS NULL AND service_id IS NULL
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")
}
if err = appendAccessAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func validOrganizationRoleReplacement(expected []string, replacement access.Binding, ownerRole string, audit access.AuditEvent) bool {
if !safeName(ownerRole) || !opaqueID(replacement.ID) || replacement.SubjectKind != access.User || !opaqueID(replacement.SubjectID) || !safeName(replacement.Role) || replacement.Scope.Validate() != nil || replacement.Scope.ProjectID != "" || replacement.Scope.EnvironmentID != "" || replacement.Scope.ServiceID != "" || !opaqueID(replacement.GrantedBy) || replacement.GrantedAt.IsZero() {
return false
}
if !validAccessAudit(audit) || audit.OrganizationID != replacement.Scope.OrganizationID || audit.ActorUserID != replacement.GrantedBy || audit.Action != "access.role.replace" || audit.ResourceType != "user" || audit.ResourceID != replacement.SubjectID || !audit.CreatedAt.Equal(replacement.GrantedAt) {
return false
}
if len(expected) > 16 || !slices.IsSorted(expected) {
return false
}
for index, id := range expected {
if !opaqueID(id) || index > 0 && expected[index-1] == id {
return false
}
}
return true
}
func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error {
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
return errors.New("authsqlite: invalid break-glass event")