Support atomic organization role sets and owner-managed invitations
verify / verify (push) Successful in 4m17s
verify / verify (push) Successful in 4m17s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
+109
-27
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user