Multi-party approval (MPA) is the principle that certain actions — removing a team member, exporting a vault, resetting someone’s password — should require more than one authorised person to sign off before they execute. It’s a standard control in finance and security infrastructure, and it’s surprisingly underrepresented in SaaS tooling.

HexVault implements MPA for all destructive or sensitive credential operations. This post covers the complete implementation: the database schema, the action registry, the vote endpoint, quorum logic, auto-expiry, and several edge cases that aren’t obvious until you hit them in production.

The goal of MPA is not to make things harder. It’s to ensure that a single compromised admin account cannot do irreversible damage unilaterally.

Why multi-party approval

Most SaaS admin panels give every admin full unilateral power. If an admin account is compromised — phished, credential-stuffed, or just used by a disgruntled employee — the attacker can remove members, export data, or reset passwords without any second check.

MPA addresses this by requiring a quorum of admins to approve certain actions before they execute. The action is created as a “pending” record, notified to other admins, and only executed once enough votes are collected. Critically, the initiator’s own vote counts — so for a 2-of-N policy, you need one other admin to agree. For a 1-of-N policy, the action executes immediately (which is useful for lower-risk operations like changing a security policy).

In a zero-knowledge credential vault, MPA has an additional property: because the server can’t read vault contents, a compromised admin can’t silently exfiltrate data. MPA closes the remaining gap by ensuring they also can’t silently remove members or export the encrypted blob without a second pair of eyes.

Database schema

The schema has three tables: the action registry, the votes, and the per-org policy overrides.

SQL
CREATE TABLE pending_actions (
    id                SERIAL PRIMARY KEY,
    org_id            INTEGER NOT NULL REFERENCES organisations(id),
    action_type       VARCHAR(64) NOT NULL,
    initiated_by      INTEGER NOT NULL,
    target_type       VARCHAR(32),
    target_id         INTEGER,
    action_payload    JSONB DEFAULT '{}',
    required_approvals INTEGER NOT NULL DEFAULT 2,
    current_approvals  INTEGER NOT NULL DEFAULT 0,
    status            VARCHAR(16) NOT NULL DEFAULT 'pending',
    expires_at        TIMESTAMPTZ NOT NULL,
    executed_at       TIMESTAMPTZ,
    created_at        TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE pending_action_votes (
    id         SERIAL PRIMARY KEY,
    action_id  INTEGER NOT NULL REFERENCES pending_actions(id),
    voter_id   INTEGER NOT NULL,
    vote       VARCHAR(8) NOT NULL CHECK (vote IN ('approve', 'reject')),
    voted_at   TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (action_id, voter_id)
);

CREATE TABLE org_approval_policies (
    org_id             INTEGER NOT NULL REFERENCES organisations(id),
    action_type        VARCHAR(64) NOT NULL,
    required_approvals INTEGER NOT NULL DEFAULT 2,
    expires_hours      INTEGER NOT NULL DEFAULT 48,
    PRIMARY KEY (org_id, action_type)
);

A few design decisions worth noting. action_payload is JSONB rather than individual columns — different action types carry different metadata, and JSONB avoids a proliferation of nullable columns. The UNIQUE (action_id, voter_id) constraint on votes prevents double-voting at the database level rather than relying on application logic. And expires_at is set at creation time from the policy, so the expiry is immutable once the action is created.

Defining which actions require MPA

Rather than hardcoding MPA requirements throughout the codebase, HexVault maintains a central registry:

Code
MPA_ACTIONS = {
    'remove_member'         : (2, 'Remove a team member'),
    'bulk_remove_members'   : (2, 'Remove multiple team members'),
    'export_vault'          : (2, 'Export the vault'),
    'change_security_policy': (1, 'Change security policy'),
    'disable_member_2fa'    : (2, 'Disable 2FA for a member'),
    'reset_member_password' : (2, 'Reset a member password'),
    'transfer_org_ownership': (2, 'Transfer organisation ownership'),
    'delete_organisation'   : (2, 'Delete the organisation'),
    'bulk_offboard'         : (2, 'Bulk offboard members'),
}

Each entry maps an action type string to a tuple of (required_approvals, human_readable_description). The required approvals figure is the default — orgs can override it via org_approval_policies. The description is used in notifications and the admin UI.

A helper function resolves the effective policy for a given org and action type, falling back to the global default if no override exists:

Python
def get_mpa_policy(org_id, action_type):
    row = db.execute(
        class="tok-st">'SELECT required_approvals, expires_hours FROM org_approval_policies '
        class="tok-st">'WHERE org_id=%s AND action_type=%s',
        (org_id, action_type), fetch=class="tok-st">'one'
    )
    if row:
        d = row if isinstance(row, dict) else {
            class="tok-st">'required_approvals': row[0], class="tok-st">'expires_hours': row[1]
        }
        return d[class="tok-st">'required_approvals'], d[class="tok-st">'expires_hours']
    default_req = MPA_ACTIONS.get(action_type, (2, class="tok-st">''))[0]
    return default_req, 48

Creating a pending action

When an admin triggers a protected action, instead of executing it directly, the application calls create_pending_action. This inserts the pending record, casts the initiator’s automatic approve vote, and dispatches notifications:

Python
def create_pending_action(org_id, initiator_id, action_type,
                           target_type=None, target_id=None, payload=None):
    required, expires_h = get_mpa_policy(org_id, action_type)

    row = db.execute(
        class="tok-st">"""INSERT INTO pending_actions
           (org_id, action_type, initiated_by, target_type, target_id,
            action_payload, required_approvals, expires_at)
           VALUES(%s,%s,%s,%s,%s,%s,%s, NOW() + (%s || class="tok-st">' hours')::INTERVAL)
           RETURNING idclass="tok-st">""",
        (org_id, action_type, initiator_id, target_type, target_id,
         json.dumps(payload or {}), required, str(expires_h)),
        fetch=class="tok-st">'one'
    )
    action_id = row[class="tok-st">'id']

    class=class="tok-st">"tok-cm"># Initiator automatically votes approve
    db.execute(
        class="tok-st">'INSERT INTO pending_action_votes(action_id, voter_id, vote) '
        class="tok-st">'VALUES(%s,%s,%s)',
        (action_id, initiator_id, class="tok-st">'approve')
    )
    db.execute(
        class="tok-st">'UPDATE pending_actions SET current_approvals=1 WHERE id=%s',
        (action_id,)
    )

    class=class="tok-st">"tok-cm"># Notify other admins
    desc = MPA_ACTIONS.get(action_type, (None, action_type))[1]
    _notify_admins_mpa(org_id, initiator_id, action_id, desc, required)

    return action_id, required

The initiator auto-vote is important: it means a 1-of-N action executes immediately (the initiator’s vote satisfies the quorum), while a 2-of-N action needs one additional approval. It also means the initiator can’t later reject their own action by voting against it — the UNIQUE constraint prevents a second vote.

Implementation note

The expires_at interval uses PostgreSQL’s interval casting: NOW() + ('48' || ' hours')::INTERVAL. This is slightly awkward but avoids a separate date arithmetic step in Python and keeps the expiry calculation on the database side where it’s consistent regardless of application server timezone.

The vote endpoint

The vote endpoint handles both approve and reject votes. It validates the voter is an admin in the same org, checks the action is still pending and unexpired, prevents the initiator from voting (they’ve already voted), and then either increments the approval count or rejects the action outright:

Python
@app.route(class="tok-st">'/api/mpa/<int:action_id>/vote', methods=[class="tok-st">'POST'])
def vote_on_action(action_id):
    user_id = request.user_id
    vote = (request.json or {}).get(class="tok-st">'vote', class="tok-st">'').lower()
    if vote not in (class="tok-st">'approve', class="tok-st">'reject'):
        return jsonify({class="tok-st">'error': class="tok-st">"Vote must be 'approve' or 'reject'"}), 400

    action = db.execute(
        class="tok-st">'SELECT id, org_id, action_type, target_id, action_payload, '
        class="tok-st">'required_approvals, current_approvals, initiated_by, status '
        class="tok-st">'FROM pending_actions WHERE id=%s AND org_id=%s',
        (action_id, org_id), fetch=class="tok-st">'one'
    )
    if not action:
        return jsonify({class="tok-st">'error': class="tok-st">'Action not found'}), 404
    if action[class="tok-st">'status'] != class="tok-st">'pending':
        return jsonify({class="tok-st">'error': fclass="tok-st">"Action is {action['status']}"}), 409
    if action[class="tok-st">'initiated_by'] == user_id:
        return jsonify({class="tok-st">'error': class="tok-st">'Initiator cannot vote separately'}), 403

    class=class="tok-st">"tok-cm"># Record the vote(UNIQUE constraint prevents double-voting)
    try:
        db.execute(
            class="tok-st">'INSERT INTO pending_action_votes(action_id, voter_id, vote) '
            class="tok-st">'VALUES(%s,%s,%s)',
            (action_id, user_id, vote)
        )
    except Exception:
        return jsonify({class="tok-st">'error': class="tok-st">'Already voted'}), 409

    if vote == class="tok-st">'reject':
        db.execute(
            class="tok-st">"UPDATE pending_actions SET status='rejected' WHERE id=%s",
            (action_id,)
        )
        return jsonify({class="tok-st">'status': class="tok-st">'rejected'}), 200

    class=class="tok-st">"tok-cm"># Approve path — increment and check quorum
    db.execute(
        class="tok-st">'UPDATE pending_actions SET current_approvals = current_approvals + 1 '
        class="tok-st">'WHERE id=%s', (action_id,)
    )
    updated = db.execute(
        class="tok-st">'SELECT current_approvals, required_approvals FROM pending_actions '
        class="tok-st">'WHERE id=%s', (action_id,), fetch=class="tok-st">'one'
    )
    if updated[class="tok-st">'current_approvals'] >= updated[class="tok-st">'required_approvals']:
        return _execute_action(action)

    return jsonify({class="tok-st">'status': class="tok-st">'pending',
                    class="tok-st">'current': updated[class="tok-st">'current_approvals'],
                    class="tok-st">'required': updated[class="tok-st">'required_approvals']}), 200

Quorum and execution

When the approval count reaches the required threshold, _execute_action runs the actual operation. This is a dispatcher that looks up the action type and calls the appropriate handler:

Python
def _execute_action(action):
    action_type = action[class="tok-st">'action_type']
    payload     = action.get(class="tok-st">'action_payload') or {}
    if isinstance(payload, str):
        payload = json.loads(payload)

    db.execute(
        class="tok-st">"UPDATE pending_actions SET status='approved', executed_at=NOW() "
        class="tok-st">"WHERE id=%s", (action[class="tok-st">'id'],)
    )

    if action_type == class="tok-st">'remove_member':
        return _exec_remove_member(action[class="tok-st">'target_id'], action[class="tok-st">'org_id'])
    elif action_type == class="tok-st">'bulk_remove_members':
        return _exec_bulk_remove(payload.get(class="tok-st">'member_ids', []), action[class="tok-st">'org_id'])
    elif action_type == class="tok-st">'export_vault':
        return _exec_export_vault(action[class="tok-st">'org_id'], payload)
    elif action_type == class="tok-st">'bulk_offboard':
        return _exec_bulk_offboard(payload.get(class="tok-st">'member_ids', []), action[class="tok-st">'org_id'])
    class=class="tok-st">"tok-cm"># ... other action types

    return jsonify({class="tok-st">'status': class="tok-st">'approved', class="tok-st">'executed': True}), 200

Marking the action as approved before executing is deliberate. If the execution handler throws, the action is still marked approved — which means a retry won’t trigger a duplicate execution after a re-vote. For actions that are truly idempotent this isn’t critical, but for remove_member it prevents removing the same person twice if the handler is called again.

Auto-expiry

Pending actions that don’t reach quorum within the policy window should expire automatically. This runs as a scheduled job every 5 minutes:

Python
def expire_pending_actions():
    expired = db.execute(
        class="tok-st">"""UPDATE pending_actions
           SET status = class="tok-st">'expired'
           WHERE status = class="tok-st">'pending'
             AND expires_at < NOW()
           RETURNING id, org_id, action_typeclass="tok-st">""",
        fetch=class="tok-st">'all'
    ) or []
    for row in expired:
        a = row if isinstance(row, dict) else {
            class="tok-st">'id': row[0], class="tok-st">'org_id': row[1], class="tok-st">'action_type': row[2]
        }
        desc = MPA_ACTIONS.get(a[class="tok-st">'action_type'], (None, a[class="tok-st">'action_type']))[1]
        push_admin_notification(
            a[class="tok-st">'org_id'], class="tok-st">'mpa_expired',
            fclass="tok-st">'Approval request expired: {desc}',
            link=class="tok-st">'class="tok-cm">#approvals'
        )

The RETURNING clause lets the job notify admins about each expiry in the same operation rather than requiring a separate select. For high-volume deployments you’d want to batch the notifications, but for typical team sizes this is fine.

Edge cases

Several things break naive MPA implementations that aren’t immediately obvious:

The race condition on quorum

If two admins approve simultaneously, both might read current_approvals = 1, both increment to 2, and both call _execute_action. The fix is to use a SELECT FOR UPDATE on the pending action row before incrementing, or to use an atomic UPDATE ... RETURNING and only execute if the returned value equals required_approvals exactly — not just “at least”.

SQL
# Atomic increment + check — only executes once
result = db.execute(
    """UPDATE pending_actions
       SET current_approvals = current_approvals + 1
       WHERE id = %s AND status = 'pending'
         AND current_approvals + 1 = required_approvals
       RETURNING id""",
    (action_id,), fetch='one'
)
if result:
    _execute_action(action)  # only one writer will get here

The single-admin org problem

If an org has only one admin and a 2-of-N policy, protected actions can never execute — there’s no second approver. HexVault handles this by checking admin count when creating the action and returning a specific error if the quorum is unreachable. The UI then prompts the org owner to either add another admin or lower the required approvals for that action type.

Cascade on member removal

When a member is removed (possibly via MPA itself), any pending actions they initiated or voted on need to be handled. HexVault leaves them in place — the action stays pending until it reaches quorum or expires. Orphaning an initiated action on the initiator’s removal is cleaner than cancelling it, because other admins may legitimately want it to proceed.

Replay after rejection

A rejected action should not be re-openable. The status field covers this — once rejected, the vote endpoint returns 409. But it’s worth explicitly checking that your UI doesn’t offer a “re-submit” button that creates a new pending action for the same target, because that would effectively bypass the rejection. HexVault rate-limits action creation per target per action type within a short window.

What zero-knowledge changes

In a conventional SaaS, MPA is primarily an authorisation control. In a zero-knowledge vault, it has an additional property: because the server can’t read vault contents, the only way to cause real damage is through the actions the server can execute — member removal, password resets, vault export (of ciphertext). MPA wraps all of those.

This means a compromised admin account on HexVault has limited blast radius: they can read metadata, view audit logs, and initiate MPA actions — but they can’t read passwords, and they can’t execute destructive actions without a second admin signing off. The combination of zero-knowledge encryption and MPA gives meaningful defence-in-depth that neither provides alone.

Zero-knowledge means the server can’t read your data. MPA means a single compromised account can’t act on it unilaterally. Together they close most of the realistic attack surface on a credential vault.

The one gap is the case where the org has only one admin. In that scenario, MPA policies with required_approvals > 1 effectively become inoperable, which is a different kind of problem. HexVault surfaces this as a health check warning and nudges single-admin orgs to either add a second admin or explicitly acknowledge the reduced protection.

If you’re building team access controls in Flask, MPA is worth the implementation cost. The schema is straightforward, the vote logic is about 80 lines, and the protection against single-point-of-failure admin compromise is significant. The edge cases are manageable once you know to look for them.