Team RBAC
Driftstack supports multi-user teams: one owner-account plus zero or
more member-accounts joined to it via the /v1/team/* endpoints.
Each member uses their own login + their own dashboard sessions. API
keys remain account-scoped (shared across the team) and admin-gated.
Status (V-326e — May 2026): end-to-end. Members can read the owner’s resources via
X-Driftstack-Account: acc_<owner-uuid>on any/v1/*request; members with theadminrole can also write. See “Acting on behalf of an owner” below.
Concepts
- Owner account. The account that pays the subscription. Shows up
as
owner_account_idon every membership and invite. - Member account. A separate
accountsrow (own login, own email) joined to the owner viateam_members. Cascade-delete on either side removes the membership. - Invite. A pending double-opt-in record in
team_invites. Generated by the owner; consumed by the invitee. Token-hashed at rest (sha256), 7-day expiry. - Role.
member(read-only on owner resources) oradmin(full read + write).
Acting on behalf of an owner
Once a member is on a team, they can scope ANY request to the
owner’s resources by passing the X-Driftstack-Account header:
GET /v1/sessions
Authorization: Bearer ds_live_<member-key>
X-Driftstack-Account: acc_<owner-uuid>
The server validates that the caller is on the team referenced by the header. The response shape is unchanged; resources are scoped to the owner instead of the caller.
Role gating:
- Read endpoints (GET) accept both
memberandadminroles. - Write endpoints (POST / PATCH / DELETE / api-keys rotate)
require
adminrole on the team.memberrole gets403.
Endpoints that honor the header (V-326 / V-330):
/v1/sessions(GET / POST / DELETE) +/:id/{navigate,interact, wait,capture,gui-input,state}/v1/profiles(GET / POST / PATCH / DELETE)/v1/api-keys(GET / POST / DELETE /:id/rotate)/v1/webhooks(GET / POST / DELETE) +/:id/deliveries+/v1/webhook-deliveries/:id/replay/v1/account/audit-log(GET) +/audit-log/export(GET)/v1/account/email-preferences(GET / PUT — PUT admin-only)/v1/usage+/v1/usage/series(GET)
Endpoints that do not honor the header (operate on the caller’s own account regardless):
/v1/team/*— managing your own team (members + invites)./v1/account/me— always your own profile (with the team list populated so clients know which owners they can act for)./v1/auth/*— authentication is per-caller.
Inverse view: the teams I am ON
Members can list the owners they’re a member of:
GET /v1/team/owners — returns { data: TeamOwner[] } where each
entry has owner_account_id, role, and membership_id. Useful
for populating an “act as” picker in custom dashboards. The same
data is also embedded in GET /v1/account/me under teams[].
Invite a team member
POST /v1/team/invites — sends an email with a 7-day accept link to
the invitee.
Request:
{ "email": "[email protected]", "role": "member" }
Response (202):
{ "message": "Invite sent. The invitee can accept via the email link." }
The invitee receives an email containing a token URL. They sign up on Driftstack first if they don’t already have an account, then accept.
List pending invites
GET /v1/team/invites — pending (unaccepted, unexpired) invites for
the calling owner.
{
"data": [
{
"id": "inv_…",
"owner_account_id": "acc_…",
"invitee_email": "[email protected]",
"role": "member",
"expires_at": "2026-05-15T10:00:00Z",
"invited_by_account_id": "acc_…",
"accepted_at": null,
"created_at": "2026-05-08T10:00:00Z"
}
]
}
Accept an invite
POST /v1/team/invites/accept — invitee calls this after signing in
to their own Driftstack account.
Request:
{ "token": "<token-from-email>" }
Response (200):
{
"membership": {
"id": "mem_…",
"owner_account_id": "acc_…",
"member_account_id": "acc_…",
"member_email": "[email protected]",
"role": "member",
"invited_at": "2026-05-08T10:00:00Z",
"accepted_at": "2026-05-08T10:05:00Z",
"invited_by_account_id": "acc_…"
}
}
The accepting account’s email MUST match the invitee email — server returns 409 Conflict otherwise. This prevents accidentally accepting an invite addressed to someone else even if they share the URL.
List members
GET /v1/team/members — confirmed memberships for the calling owner.
Remove a member
DELETE /v1/team/members/:id — owner-scoped. 404 if the membership
isn’t owned by the calling account.
SDK examples
TypeScript:
await client.team.invite('[email protected]', { role: 'admin' });
const members = await client.team.listMembers();
await client.team.acceptInvite(tokenFromEmail);
await client.team.removeMember('mem_…');
Python:
client.team.invite("[email protected]", role="admin")
members = client.team.list_members()
client.team.accept_invite(token_from_email)
client.team.remove_member("mem_…")
Go:
client.Team.Invite(ctx, &driftstack.TeamInviteRequest{
Email: "[email protected]",
Role: driftstack.TeamRoleAdmin,
})
members, _ := client.Team.ListMembers(ctx)
client.Team.AcceptInvite(ctx, tokenFromEmail)
client.Team.RemoveMember(ctx, "mem_…")
Audit log
Every team mutation writes an entry to the customer audit log
(/v1/account/audit-log):
| Action | Fires when |
|---|---|
team.member_invited | owner calls POST /v1/team/invites |
team.invite_accepted | invitee calls POST /v1/team/invites/accept |
team.member_removed | owner calls DELETE /v1/team/members/:id |
Use the audit-log export (V-297) to download the team history at any time:
curl -H "Authorization: Bearer $DRIFTSTACK_API_KEY" \
"https://api.driftstack.dev/v1/account/audit-log/export?format=csv" \
> team-history.csv
Privacy / DPA
A member is a separate Data Subject from the owner. Their account email is processed under Privacy §3.1 (Account data) on the same legal basis as any other Customer contact.