Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,21 @@ scute.users.find_by_identifier("ada@example.com") # exact email (any case) or p
scute.users.update(user_id, user_meta={"plan": "team"})
scute.users.deactivate(user_id)

scute.users.previous_accounts(user_id) # earlier, deleted accounts of the same person
scute.users.merge(user_id, from_id) # move one's roles, passkeys and MFA over (once)

scute.sessions.list(user_id) # the secret key alone is enough, no user session
scute.sessions.revoke(user_id, session_id) # ends it at once
scute.sessions.current_user(access_token)
scute.sessions.sign_out(access_token)
```

Someone deleted who signs in again gets a fresh account (a new id); their
earlier account stays deleted, with its history. `previous_accounts` lists the
earlier accounts and `merge` brings one into the live account (for meta and
attributes the live account wins). Someone deactivated and then deleted can't
sign in again (403, `account_deactivated`) until you bring them back.

`meta` and `user_meta` keys are the user meta fields declared for the app (in
the dashboard): others are left out on create (listed in `user_meta_errors`)
and refused on update.
Expand Down Expand Up @@ -177,5 +186,5 @@ unexpected pass to clean up.

Each run is named `live-<runid>` (users, roles, resources, agents,
properties) and deletes what it made at the end, also when tests fail. It
keeps sign-ins to five per run (the API throttles them) and never prints the
keeps sign-ins to seven per run (the API throttles them) and never prints the
secret, tokens or codes other than 424242.
22 changes: 22 additions & 0 deletions src/scute/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ def deactivate(self, user_id: str) -> Any:
def delete(self, user_id: str) -> Any:
return self._c.request("DELETE", self._c.app_path(f"/users/{self._c.esc(user_id)}"))

# ── Previous accounts ──
# Someone deleted who signs in again gets a fresh account (a new id). Their
# earlier accounts stay deleted, and can be merged into the live one.

def previous_accounts(self, user_id: str) -> builtins.list[dict[str, Any]]:
"""The person's earlier, deleted accounts in this app, newest first: id,
status, created_at, deleted_at, merged_into (once merged), and what each
still holds (roles and passkeys counted, mfa_methods listed)."""
data = self._c.request("GET", self._c.app_path(f"/users/{self._c.esc(user_id)}/previous_accounts"))
return list((data or {}).get("previous_accounts") or [])

def merge(self, user_id: str, from_id: str) -> dict[str, Any]:
"""Merge a previous (deleted) account of the same person into this live
one. Roles, resource roles, passkeys, MFA methods and unused backup codes
move over; meta and attributes are combined, the live account winning;
history stays on the old account. Answers {user_id, merged, moved: {roles,
resource_roles, passkeys, mfa_methods, backup_codes}} (counts moved).

Once per account: merging it again raises APIError (422, code
"already_merged")."""
return dict(self._c.request("POST", self._c.app_path(f"/users/{self._c.esc(user_id)}/merge"), {"from": from_id}))

# ── Signing in as a user (support access) ──
# Off until the app turns it on. The session is short, never refreshed,
# and its token names who is really acting (Session.actor).
Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class FakeScute:
{"id": "user6", "email": "zoe@example.com", "phone": None},
])
revoked: set[str] = field(default_factory=set)
merged: set[str] = field(default_factory=set)
seen: list[httpx.Request] = field(default_factory=list)

def paths(self, path: str) -> list[httpx.Request]:
Expand Down Expand Up @@ -91,6 +92,15 @@ def ok(data: Any, status: int = 200) -> httpx.Response:
return ok({"users": found, "current_page": page, "next_page": page + 1 if more else None, "per_page": size})
if path == "/v1/auth/app1/users" and method == "POST":
return ok({"user": {"id": "user2", "identifier": body["identifier"]}}, 201)
if path == "/v1/app1/users/user1/previous_accounts" and method == "GET":
return ok({"previous_accounts": [{"id": "old1", "status": "active", "deleted_at": "2026-09-01T00:00:00Z",
"roles": 1, "passkeys": 0, "mfa_methods": ["totp"]}]})
if path == "/v1/app1/users/user1/merge" and method == "POST":
if body["from"] in self.merged:
return ok({"error": "That account was already merged", "error_code": "already_merged"}, 422)
self.merged.add(body["from"])
return ok({"user_id": "user1", "merged": body["from"],
"moved": {"roles": 1, "resource_roles": 0, "passkeys": 0, "mfa_methods": 1, "backup_codes": 0}})
if path.startswith("/v1/app1/users/user1"):
if path.endswith("/sessions"):
return ok([{"id": "ses1"}])
Expand Down
64 changes: 62 additions & 2 deletions tests/live/test_05_users.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""5. Admin users with the secret key: create, get (by id and by identifier),
update, deactivate, activate, delete. All SDK (scute.users).
update, deactivate, activate, delete; and after a delete, the fresh account a
returning user gets, their previous accounts and merging one in. All SDK
(scute.users); the sign-ins go through the HTTP helper.

Not run: users.invite sends a real magic-link email (test identities only
cover OTP codes).
Expand All @@ -11,7 +13,16 @@

from scute import APIError, Scute

from .support import Cleanup, Names, delete_user_later
from .support import (
Cleanup,
LiveAPI,
Names,
Policy,
assign_role,
delete_user_later,
revoke_role_later,
sign_in,
)

pytestmark = pytest.mark.live

Expand Down Expand Up @@ -86,3 +97,52 @@ def test_meta_needs_declared_fields(scute: Scute, names: Names, cleanup: Cleanup
with pytest.raises(APIError) as refused:
scute.users.update(user_id, user_meta={key: "team"})
assert refused.value.status == 422


def test_a_deleted_user_who_signs_in_again_gets_a_fresh_account(api: LiveAPI, scute: Scute, names: Names,
cleanup: Cleanup, policy: Policy) -> None:
"""Deleted, then signed in again: a new account, the old one listed as a
previous account and merged in (its role moves over), once."""
email = names.email(11)
first = sign_in(api, email)
delete_user_later(scute, cleanup, first.user_id)
assign_role(api, cleanup, first.user_id, policy.auditor)
scute.users.delete(first.user_id)

again = sign_in(api, email)
delete_user_later(scute, cleanup, again.user_id)
revoke_role_later(api, cleanup, again.user_id, policy.auditor) # the role moves over below
assert again.user_id != first.user_id
with pytest.raises(APIError) as gone:
scute.users.get(first.user_id)
assert gone.value.status == 404
target = f"{policy.invoice}:1"
assert not scute.authz.check(user_id=again.user_id, action="read", resource=target).allowed

previous = scute.users.previous_accounts(again.user_id)
assert [a["id"] for a in previous] == [first.user_id]
assert previous[0]["deleted_at"] and previous[0]["roles"] == 1 and "merged_into" not in previous[0]

merged = scute.users.merge(again.user_id, first.user_id)
assert (merged["user_id"], merged["merged"], merged["moved"]["roles"]) == (again.user_id, first.user_id, 1)
held = api.ok("GET", api.apps(f"/authz/users/{again.user_id}/roles"))["roles"]
assert [g["role"] for g in held] == [policy.auditor]
assert scute.authz.check(user_id=again.user_id, action="read", resource=target).allowed
assert scute.users.previous_accounts(again.user_id)[0]["merged_into"] == again.user_id

with pytest.raises(APIError) as twice:
scute.users.merge(again.user_id, first.user_id)
assert (twice.value.status, twice.value.code) == (422, "already_merged")


def test_someone_deactivated_then_deleted_cant_sign_in_again(api: LiveAPI, scute: Scute, names: Names,
cleanup: Cleanup) -> None:
"""Deprovisioned (deactivated, then deleted): no fresh account, the sign-in is refused."""
email = names.email(12)
user_id = str(scute.users.create(email)["user"]["id"])
delete_user_later(scute, cleanup, user_id)
scute.users.deactivate(user_id)
scute.users.delete(user_id)
refused = api.call("POST", api.auth("/otps/login"), {"identifier": email}, secret=False)
assert (refused.status, refused.error_code) == (403, "account_deactivated")
assert scute.users.find_by_identifier(email) is None
17 changes: 17 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import json

import httpx
import pytest

Expand Down Expand Up @@ -49,6 +51,21 @@ def test_the_search_stops_after_a_bounded_number_of_pages(fake: FakeScute) -> No
assert len(fake.seen) == client(fake).users.FIND_MAX_PAGES


def test_lists_and_merges_previous_accounts(fake: FakeScute) -> None:
c = client(fake)
previous = c.users.previous_accounts("user1")
assert [(a["id"], a["roles"], a["mfa_methods"]) for a in previous] == [("old1", 1, ["totp"])]
merged = c.users.merge("user1", "old1")
assert (merged["merged"], merged["moved"]["roles"]) == ("old1", 1)
assert (fake.seen[-1].method, fake.seen[-1].url.path) == ("POST", "/v1/app1/users/user1/merge")
assert json.loads(fake.seen[-1].content) == {"from": "old1"}
assert fake.seen[-1].headers["authorization"] == "Bearer sk_test"
with pytest.raises(APIError) as again:
c.users.merge("user1", "old1")
assert (again.value.status, again.value.code) == (422, "already_merged")
assert len(fake.paths("/v1/app1/users/user1/merge")) == 2 # a POST isn't retried


def test_starts_lists_and_ends_sessions_as_a_user(fake: FakeScute) -> None:
c = client(fake)
started = c.users.impersonate("user1", reason="Ticket 4411", actor={"email": "support@acme.test"}, minutes=15)
Expand Down
Loading