This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user