| Policy Name |
|---|
{KEY_ID}")), "{body}");
+ assert!(
+ body.contains(r#"action="/console/accounts/444455556666/users/bob/access-keys/new""#),
+ "{body}"
+ );
+ assert!(
+ body.contains(&format!(
+ r#"action="/console/accounts/444455556666/users/bob/access-keys/{KEY_ID}/delete""#
+ )),
+ "{body}"
+ );
+}
+
+#[tokio::test]
+async fn iam_user_page_leaves_out_policies_groups_and_tags() {
+ let response = render_user_page(&session(iam_user()), ACCOUNT, USER, Some(&detail()));
+
+ let body = body_text(response).await;
+ for absent in [
+ POLICY,
+ GROUP,
+ TAG_KEY,
+ TAG_VALUE,
+ "Accounts
" not in page.text - assert f'' in page.text + assert f'' in page.text for name in world.a.names() + [world.a.id]: assert name not in page.text, name diff --git a/tests/test_console_integration.py b/tests/test_console_integration.py index 994a9f6f0..d1dd21930 100755 --- a/tests/test_console_integration.py +++ b/tests/test_console_integration.py @@ -781,15 +781,17 @@ def test_iam_user_sees_own_account_only(self): resp = iam_console.login(f"{self.account_id}/{user}", password) assert resp.status_code == 303 - # Dashboard should be accessible. + # Dashboard should be accessible, with a link to the user's own + # page. resp = iam_console.get("") assert resp.status_code == 200 + assert f"/console/accounts/{self.account_id}/users/{user}" in resp.text - # Account listing: IAM user should see limited view. + # The account list is for admins: an IAM user is redirected to + # the dashboard. resp = iam_console.get("/accounts") - assert resp.status_code == 200 - # The IAM user's own account should be visible. - assert self.account_id in resp.text + assert resp.status_code == 303 + assert resp.headers.get("location") == "/console" finally: self.admin_console.post( f"/accounts/{self.account_id}/users/{user}/delete" diff --git a/tests/test_cross_account_isolation.py b/tests/test_cross_account_isolation.py index cb88bc3d6..06ff1839b 100755 --- a/tests/test_cross_account_isolation.py +++ b/tests/test_cross_account_isolation.py @@ -356,27 +356,29 @@ def _console_login(self, account_id: str, user_name: str, return session def test_alice_console_does_not_show_bob_account(self): - """Alice's console session does not list bob's account.""" + """Alice's console session does not list bob's account: the account + list is for admins and redirects her to the dashboard.""" session = self._console_login(self.alice_acct, "alice", "AlicePass123!") resp = session.get( f"{self.endpoint}/console/accounts", allow_redirects=False, timeout=30, ) - assert resp.status_code == 200 - assert self.alice_acct in resp.text + assert resp.status_code == 303 + assert resp.headers.get("location") == "/console" assert self.bob_acct not in resp.text def test_bob_console_does_not_show_alice_account(self): - """Bob's console session does not list alice's account.""" + """Bob's console session does not list alice's account: the account + list is for admins and redirects him to the dashboard.""" session = self._console_login(self.bob_acct, "bob", "BobPass456!") resp = session.get( f"{self.endpoint}/console/accounts", allow_redirects=False, timeout=30, ) - assert resp.status_code == 200 - assert self.bob_acct in resp.text + assert resp.status_code == 303 + assert resp.headers.get("location") == "/console" assert self.alice_acct not in resp.text From 1e22ad49c920391acab2a331eeb374592ea6e694 Mon Sep 17 00:00:00 2001 From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:54:37 +0000 Subject: [PATCH 2/4] test(console): check that an IAM user reads only its own user page and keys tests/test_console_iam_user_pages.py logs in as an IAM user that holds only the self-service policy every user created with a console password gets. The account also holds two other users, a group and a role, each with an inline policy, and one user with an access key and a tag. The tests check that the account list, the account forms and every page and form under the user's account other than its own user page and access key forms redirect it to /console, and that no refused form changes the catalog. They check that no page it opens names another user, group or role of the account or links to a page that redirects it, and that the management API refuses the user the same reads. The user still reads, creates and deletes its own access keys and logs out, and an admin session still reads every page. --- tests/test_console_iam_user_pages.py | 537 +++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 tests/test_console_iam_user_pages.py diff --git a/tests/test_console_iam_user_pages.py b/tests/test_console_iam_user_pages.py new file mode 100644 index 000000000..23ad483ea --- /dev/null +++ b/tests/test_console_iam_user_pages.py @@ -0,0 +1,537 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Console pages of an IAM user's own account serve the user its own user +page and its own access keys only. + +The management API gives an IAM user its own access keys (list, create and +delete) and its own password, and answers every other read and write with +HTTP 403. The console gives an IAM-user session the same: its own user page, +which lists its access keys, and the forms that create and delete them. +Every other page and form under its account redirects it to /console, and no +page it can open names another user, group or role, or links to a page that +redirects it. + +The account holds a user `owner` with an inline policy, an access key and a +tag; a second user `peer`; a group with `owner` as its member and an inline +policy; and a role with an inline policy. The IAM user `viewer` is created +with a console password, so it holds only the self-service policy that every +such user gets. Each name carries a random suffix, so that no page shows it +by chance. + +Prerequisites match tests/test_console_account_scope.py: + - extenddb running with `auth.provider = "builtin"` on EXTENDDB_TEST_ENDPOINT + - Admin credentials in EXTENDDB_ADMIN_USER / EXTENDDB_ADMIN_PASSWORD +""" + +from __future__ import annotations + +import json +import os +import re +import uuid +from dataclasses import dataclass +from typing import Callable + +import pytest +import requests + +from management_helpers import ManagementClient + +_CSRF_RE = re.compile(r'(AKIA[^<]+)') + +# The inline policy every user created with a console password holds. +_SELF_SERVICE_POLICY = "SelfServicePolicy" + +_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "dynamodb:GetItem", "Resource": "*"}], +} + +_FULL_ACCESS = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], +} + + +def _trust_policy(account_id: str) -> dict: + return { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": f"arn:aws:iam::{account_id}:root"}, + "Action": "sts:AssumeRole", + }], + } + + +def _require_auth_env() -> tuple[str, str, str]: + endpoint = os.environ.get("EXTENDDB_TEST_ENDPOINT", "").strip() + admin_user = os.environ.get("EXTENDDB_ADMIN_USER", "").strip() + admin_pass = os.environ.get("EXTENDDB_ADMIN_PASSWORD", "").strip() + if not endpoint or not admin_user or not admin_pass: + pytest.fail( + "MISCONFIGURED: console IAM user page tests require " + "EXTENDDB_TEST_ENDPOINT, EXTENDDB_ADMIN_USER, and " + "EXTENDDB_ADMIN_PASSWORD. These must be set by devtools/run-tests " + "before test execution." + ) + return endpoint, admin_user, admin_pass + + +class Console: + """One browser: a cookie jar talking to the server's console.""" + + def __init__(self, base_url: str) -> None: + self.base_url = base_url.rstrip("/") + self.http = requests.Session() + # Self-signed certs from ``extenddb init``. The harness sets + # REQUESTS_CA_BUNDLE, which overrides a session-level ``verify``, so + # the environment is ignored and ``verify`` is passed per request. + self.http.trust_env = False + self.verify = not self.base_url.startswith("https://") + self.csrf = "" + + def login(self, username: str, password: str) -> requests.Response: + """Log in and return the home page the login redirects to.""" + resp = self.http.post( + f"{self.base_url}/console/login", + data={"username": username, "password": password}, + allow_redirects=False, + timeout=30, + verify=self.verify, + ) + assert resp.status_code == 303, ( + f"login as {username}: {resp.status_code} {resp.text[:200]}" + ) + assert resp.headers.get("location") == "/console" + home = self.get("") + assert home.status_code == 200, home.status_code + match = _CSRF_RE.search(home.text) + assert match, "the console home page carries no CSRF token" + self.csrf = match.group(1) + return home + + def get(self, path: str) -> requests.Response: + return self.http.get( + f"{self.base_url}/console{path}", + allow_redirects=False, + timeout=30, + verify=self.verify, + ) + + def open(self, url: str) -> requests.Response: + """GET a link target as it appears in a page (`/console/...`).""" + assert url.startswith("/console"), url + return self.get(url[len("/console"):]) + + def post(self, path: str, form: dict[str, str]) -> requests.Response: + return self.http.post( + f"{self.base_url}/console{path}", + data={"_csrf": self.csrf, **form}, + allow_redirects=False, + timeout=30, + verify=self.verify, + ) + + +@dataclass +class World: + """The account, the names in it, and the sessions that read it.""" + + endpoint: str + mgmt: ManagementClient + admin: Console + viewer: Console + account_id: str + account_name: str + owner: str + peer: str + group: str + role: str + user_policy: str + group_policy: str + role_policy: str + owner_key: str + tag_key: str + tag_value: str + viewer_name: str + viewer_password: str + viewer_key: str + missing_user: str # a name that no user has + + def others(self) -> list[str]: + """Every name of the account, its other users, its group and its + role, which no page the viewer opens may show.""" + return [ + self.account_name, self.owner, self.peer, self.group, self.role, + self.user_policy, self.group_policy, self.role_policy, + self.owner_key, self.tag_key, self.tag_value, + ] + + def viewer_auth(self) -> tuple[str, str]: + return (f"{self.account_id}/{self.viewer_name}", self.viewer_password) + + +@pytest.fixture(scope="module") +def world(): + endpoint, admin_user, admin_pass = _require_auth_env() + mgmt = ManagementClient(endpoint, admin_user, admin_pass) + suffix = uuid.uuid4().hex[:10] + account_id = f"{uuid.uuid4().int % 10**12:012d}" + w = World( + endpoint=endpoint, + mgmt=mgmt, + admin=Console(endpoint), + viewer=Console(endpoint), + account_id=account_id, + account_name=f"own-pages-{suffix}", + owner=f"owner-{suffix}", + peer=f"peer-{suffix}", + group=f"group-{suffix}", + role=f"role-{suffix}", + user_policy=f"user-policy-{suffix}", + group_policy=f"group-policy-{suffix}", + role_policy=f"role-policy-{suffix}", + owner_key="", + tag_key=f"tag-key-{suffix}", + tag_value=f"tag-value-{suffix}", + viewer_name=f"viewer-{suffix}", + viewer_password=uuid.uuid4().hex, + viewer_key="", + missing_user=f"nobody-{suffix}", + ) + a = w.account_id + try: + steps: list[Callable[[], requests.Response]] = [ + lambda: mgmt.create_account(a, w.account_name), + lambda: mgmt.create_user(a, w.owner), + lambda: mgmt.create_user(a, w.peer), + lambda: mgmt.create_user(a, w.viewer_name, w.viewer_password), + lambda: mgmt.create_group(a, w.group), + lambda: mgmt.add_group_member(a, w.group, w.owner), + lambda: mgmt.create_role(a, w.role, _trust_policy(a)), + lambda: mgmt.put_user_policy(a, w.owner, w.user_policy, _POLICY), + lambda: mgmt.put_group_policy(a, w.group, w.group_policy, _POLICY), + lambda: mgmt.put_role_policy(a, w.role, w.role_policy, _POLICY), + lambda: mgmt.tag_user(a, w.owner, {w.tag_key: w.tag_value}), + ] + for step in steps: + resp = step() + assert resp.status_code in (200, 201, 204), f"{resp.url}: {resp.text}" + resp = mgmt.create_access_key(a, w.owner) + assert resp.status_code == 201, resp.text + w.owner_key = resp.json()["access_key_id"] + resp = mgmt.create_access_key(a, w.viewer_name) + assert resp.status_code == 201, resp.text + w.viewer_key = resp.json()["access_key_id"] + + w.admin.login(admin_user, admin_pass) + w.viewer.login(*w.viewer_auth()) + yield w + finally: + mgmt.delete_user_policy(a, w.owner, w.user_policy) + mgmt.delete_group_policy(a, w.group, w.group_policy) + mgmt.delete_role_policy(a, w.role, w.role_policy) + mgmt.remove_group_member(a, w.group, w.owner) + mgmt.delete_group(a, w.group) + mgmt.delete_role(a, w.role) + for user in (w.owner, w.peer, w.viewer_name): + keys = mgmt.list_access_keys(a, user) + for key in keys.json() if keys.status_code == 200 else []: + mgmt.delete_access_key(a, user, key["access_key_id"]) + mgmt.delete_user(a, user) + mgmt.delete_account(a) + + +# Console paths that answer GET and redirect an IAM-user session, each named +# by what it shows an admin. +PAGES: dict[str, Callable[[World], str]] = { + "accounts": lambda w: "/accounts", + "new-account-form": lambda w: "/accounts/new", + "account": lambda w: f"/accounts/{w.account_id}", + "owner": lambda w: f"/accounts/{w.account_id}/users/{w.owner}", + "peer": lambda w: f"/accounts/{w.account_id}/users/{w.peer}", + "missing-user": lambda w: f"/accounts/{w.account_id}/users/{w.missing_user}", + "group": lambda w: f"/accounts/{w.account_id}/groups/{w.group}", + "role": lambda w: f"/accounts/{w.account_id}/roles/{w.role}", + "new-user-form": lambda w: f"/accounts/{w.account_id}/users/new", + "new-group-form": lambda w: f"/accounts/{w.account_id}/groups/new", + "new-role-form": lambda w: f"/accounts/{w.account_id}/roles/new", + "own-policy-form": lambda w: ( + f"/accounts/{w.account_id}/users/{w.viewer_name}/policies/new"), + "owner-policy-form": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/policies/new"), + "group-policy-form": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/policies/new"), + "role-policy-form": lambda w: ( + f"/accounts/{w.account_id}/roles/{w.role}/policies/new"), +} + +# Console forms that redirect an IAM-user session, with a complete form for +# each so that the handler runs. Each one changes the catalog if it is let +# through. Several name the viewer itself: its own user, its own policies and +# its own group membership are for admins to change. +FORMS: dict[str, Callable[[World], tuple[str, dict[str, str]]]] = { + "create-account": lambda w: ( + "/accounts/new", {"account_id": "", "account_name": f"new-{w.account_name}"}), + "delete-account": lambda w: (f"/accounts/{w.account_id}/delete", {}), + "create-user": lambda w: ( + f"/accounts/{w.account_id}/users/new", {"user_name": f"new-{w.peer}", "password": ""}), + "delete-peer": lambda w: (f"/accounts/{w.account_id}/users/{w.peer}/delete", {}), + "delete-self": lambda w: ( + f"/accounts/{w.account_id}/users/{w.viewer_name}/delete", {}), + "create-owner-key": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/access-keys/new", {}), + "delete-owner-key": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/access-keys/{w.owner_key}/delete", {}), + "create-group": lambda w: ( + f"/accounts/{w.account_id}/groups/new", {"group_name": f"new-{w.group}"}), + "delete-group": lambda w: (f"/accounts/{w.account_id}/groups/{w.group}/delete", {}), + "join-group": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/members/add", + {"user_name": w.viewer_name}), + "remove-member": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/members/{w.owner}/remove", {}), + "create-role": lambda w: ( + f"/accounts/{w.account_id}/roles/new", + {"role_name": f"new-{w.role}", "trust_policy": json.dumps(_trust_policy(w.account_id))}), + "delete-role": lambda w: (f"/accounts/{w.account_id}/roles/{w.role}/delete", {}), + "put-own-policy": lambda w: ( + f"/accounts/{w.account_id}/users/{w.viewer_name}/policies/new", + {"policy_name": f"self-{w.user_policy}", "policy_document": json.dumps(_FULL_ACCESS)}), + "delete-own-policy": lambda w: ( + f"/accounts/{w.account_id}/users/{w.viewer_name}/policies/{_SELF_SERVICE_POLICY}/delete", + {}), + "put-owner-policy": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/policies/new", + {"policy_name": f"new-{w.user_policy}", "policy_document": json.dumps(_POLICY)}), + "delete-owner-policy": lambda w: ( + f"/accounts/{w.account_id}/users/{w.owner}/policies/{w.user_policy}/delete", {}), + "put-group-policy": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/policies/new", + {"policy_name": f"new-{w.group_policy}", "policy_document": json.dumps(_POLICY)}), + "delete-group-policy": lambda w: ( + f"/accounts/{w.account_id}/groups/{w.group}/policies/{w.group_policy}/delete", {}), + "put-role-policy": lambda w: ( + f"/accounts/{w.account_id}/roles/{w.role}/policies/new", + {"policy_name": f"new-{w.role_policy}", "policy_document": json.dumps(_POLICY)}), + "delete-role-policy": lambda w: ( + f"/accounts/{w.account_id}/roles/{w.role}/policies/{w.role_policy}/delete", {}), +} + + +def _assert_redirected_to_console(resp: requests.Response, what: str) -> None: + assert resp.status_code == 303, f"{what}: {resp.status_code} {resp.text[:300]}" + assert resp.headers.get("location") == "/console", what + assert resp.content == b"", f"{what}: {resp.text[:300]}" + + +def _state(w: World) -> dict[str, object]: + """What a console form could change: the admin's pages of the account, + each user, the group and the role, and whether the account list holds + this account and the account the create-account form names. The admin's + console session reads them without a password check per request, which + a management API read would need. Other test files create and delete + accounts while this one runs, so the rest of the account list is not + compared.""" + a = w.account_id + paths = [f"/accounts/{a}", f"/accounts/{a}/groups/{w.group}", + f"/accounts/{a}/roles/{w.role}"] + paths += [f"/accounts/{a}/users/{u}" for u in (w.owner, w.peer, w.viewer_name)] + state: dict[str, object] = {} + for path in paths: + page = w.admin.get(path) + assert page.status_code == 200, f"{path}: {page.status_code}" + state[path] = page.text + accounts = w.admin.get("/accounts") + assert accounts.status_code == 200, accounts.status_code + state["/accounts"] = (a in accounts.text, f"new-{w.account_name}" in accounts.text) + return state + + +@pytest.mark.parametrize("page", sorted(PAGES)) +def test_refused_page_redirects_to_console(world: World, page: str) -> None: + resp = world.viewer.get(PAGES[page](world)) + _assert_redirected_to_console(resp, page) + for name in world.others(): + assert name not in str(resp.headers), f"{page} headers name {name}" + + +def test_another_user_and_a_missing_user_get_the_same_answer(world: World) -> None: + other = world.viewer.get(PAGES["owner"](world)) + missing = world.viewer.get(PAGES["missing-user"](world)) + assert (missing.status_code, missing.headers.get("location"), missing.content) == ( + other.status_code, other.headers.get("location"), other.content) + + +@pytest.mark.parametrize("action", sorted(FORMS)) +def test_refused_form_redirects_to_console_and_changes_nothing( + world: World, action: str +) -> None: + path, form = FORMS[action](world) + before = _state(world) + + resp = world.viewer.post(path, form) + + after = _state(world) + _assert_redirected_to_console(resp, action) + changed = [page for page in before if after[page] != before[page]] + assert not changed, f"{action} changed {changed}" + + +def test_viewer_reads_its_own_user_page(world: World) -> None: + a, user = world.account_id, world.viewer_name + resp = world.viewer.get(f"/accounts/{a}/users/{user}") + + assert resp.status_code == 200, resp.status_code + assert f"{world.viewer_key}" in resp.text
+ assert f'action="/console/accounts/{a}/users/{user}/access-keys/new"' in resp.text
+ assert (f'action="/console/accounts/{a}/users/{user}/access-keys/'
+ f'{world.viewer_key}/delete"') in resp.text
+ # The management API refuses the viewer its own policies and tags, so
+ # the page leaves out the policies, groups and tags sections.
+ for absent in ("{key_id}" in page.text
+
+ deleted = world.viewer.post(f"/accounts/{a}/users/{user}/access-keys/{key_id}/delete", {})
+ assert deleted.status_code == 303, deleted.status_code
+ assert deleted.headers.get("location") == f"/console/accounts/{a}/users/{user}"
+ assert key_id not in world.mgmt.list_access_keys(a, user).text
+ assert world.viewer_key in world.mgmt.list_access_keys(a, user).text
+
+
+def _viewer_pages(world: World) -> dict[str, str]:
+ """Every page the viewer opens: the home page, its user page, and the
+ page that shows a key it creates. The key is deleted again."""
+ a, user = world.account_id, world.viewer_name
+ pages = {}
+ for name, path in (("home", ""), ("user", f"/accounts/{a}/users/{user}")):
+ resp = world.viewer.get(path)
+ assert resp.status_code == 200, f"{name}: {resp.status_code}"
+ pages[name] = resp.text
+ created = world.viewer.post(f"/accounts/{a}/users/{user}/access-keys/new", {})
+ assert created.status_code == 200, created.status_code
+ pages["new-key"] = created.text
+ match = _KEY_RE.search(created.text)
+ assert match, created.text[:500]
+ deleted = world.viewer.post(
+ f"/accounts/{a}/users/{user}/access-keys/{match.group(1)}/delete", {})
+ assert deleted.status_code == 303, deleted.status_code
+ return pages
+
+
+def test_no_page_the_viewer_opens_names_another_principal(world: World) -> None:
+ for page, text in _viewer_pages(world).items():
+ for name in world.others():
+ assert name not in text, f"the {page} page shows {name}"
+
+
+def test_viewer_pages_link_to_no_page_that_redirects_it(world: World) -> None:
+ pages = _viewer_pages(world)
+ own_page = f"/console/accounts/{world.account_id}/users/{world.viewer_name}"
+ for page in ("home", "new-key"):
+ assert f'href="{own_page}"' in pages[page], f"the {page} page has no link to {own_page}"
+ # The Metrics, Settings and Cache links are outside the account pages,
+ # and the documentation pages are public, so the check covers the home
+ # page and the pages under /console/accounts.
+ for page, text in pages.items():
+ for link in sorted(set(_HREF_RE.findall(text))):
+ if link != "/console" and not link.startswith("/console/accounts"):
+ continue
+ resp = world.viewer.open(link)
+ assert resp.status_code == 200, (
+ f"the {page} page links to {link}, which answers {resp.status_code} "
+ f"{resp.headers.get('location')}")
+
+
+def test_viewer_logs_in_manages_keys_and_logs_out(world: World) -> None:
+ a, user = world.account_id, world.viewer_name
+ browser = Console(world.endpoint)
+ home = browser.login(*world.viewer_auth())
+ own_page = f"/console/accounts/{a}/users/{user}"
+ assert f'' in home.text
+
+ page = browser.open(own_page)
+ assert page.status_code == 200, page.status_code
+ assert f"{world.viewer_key}" in page.text
+
+ resp = browser.post("/logout", {})
+ assert resp.status_code == 303, resp.status_code
+ assert resp.headers.get("location") == "/console/login"
+ resp = browser.open(own_page)
+ assert resp.status_code == 303, resp.status_code
+ assert resp.headers.get("location") == "/console/login"
+
+
+# The names each page shows an admin.
+SHOWN: dict[str, Callable[[World], tuple[str, list[str]]]] = {
+ "accounts": lambda w: ("/accounts", [w.account_id, w.account_name]),
+ "account": lambda w: (
+ f"/accounts/{w.account_id}",
+ [w.account_name, w.owner, w.peer, w.viewer_name, w.group, w.role]),
+ "owner": lambda w: (
+ f"/accounts/{w.account_id}/users/{w.owner}",
+ [w.owner_key, w.user_policy, w.group, w.tag_key, w.tag_value]),
+ "viewer": lambda w: (
+ f"/accounts/{w.account_id}/users/{w.viewer_name}",
+ [w.viewer_key, _SELF_SERVICE_POLICY]),
+ "group": lambda w: (
+ f"/accounts/{w.account_id}/groups/{w.group}", [w.owner, w.group_policy]),
+ "role": lambda w: (f"/accounts/{w.account_id}/roles/{w.role}", [w.role_policy]),
+}
+
+
+@pytest.mark.parametrize("page", sorted(SHOWN))
+def test_admin_reads_every_page_of_the_account(world: World, page: str) -> None:
+ path, names = SHOWN[page](world)
+ resp = world.admin.get(path)
+ assert resp.status_code == 200, f"{page}: {resp.status_code}"
+ for name in names:
+ assert name in resp.text, f"{page} lacks {name}"
From 33680119b89e25054156ee4f4453f0a51c691565 Mon Sep 17 00:00:00 2001
From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com>
Date: Thu, 5 Nov 2026 00:54:39 +0000
Subject: [PATCH 3/4] docs: describe the console pages an IAM user opens in its
own account
The getting started guide, the admin guide, the architecture guide and the
security model now say that an IAM-user session opens its own user page and
manages its own access keys, and that every other page under its account
redirects it to the dashboard. The upgrade manual has a v0.1.15 entry for
the change.
The earlier v0.1.15 entry for console account scoping no longer says
that the home page links an IAM user to its own account, or that the
pages of the user's own account are unchanged.
---
docs/getting-started.md | 4 ++--
docs/manuals/01-architecture-guide.md | 2 +-
docs/manuals/05-admin-guide.md | 12 ++++++------
docs/manuals/07-upgrade-manual.md | 18 ++++++++++++++++--
docs/manuals/10-security-model.md | 2 +-
5 files changed, 26 insertions(+), 12 deletions(-)
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 712f34cfe..cf78a0403 100755
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1270,7 +1270,7 @@ Navigate to `https://127.0.0.1:18443/console/` in your browser (adjust the host
### Features
-- **Dashboard:** Overview of accounts and admin users (admin sessions only; an IAM-user session gets a link to its own account).
+- **Dashboard:** Overview of accounts and admin users (admin sessions only; an IAM-user session gets a link to its own user page).
- **Account management:** Create, view, and delete accounts (admin only).
- **User management:** Create and delete IAM users, view access keys, policies, tags, and group memberships.
- **Access key management:** Create and delete access keys. The secret key is shown once at creation time.
@@ -1278,7 +1278,7 @@ Navigate to `https://127.0.0.1:18443/console/` in your browser (adjust the host
- **Role management:** Create and delete roles, view trust policies.
- **Policy management:** Add and delete inline policies for users, groups, and roles. Includes a JSON editor with a default policy template.
-Admin users have full access to all management operations. IAM users can view their own details and manage their own access keys.
+Admin users have full access to all management operations. IAM users open their own user page, which lists their access keys, and create and delete their own access keys there. These are the access key reads and writes the management API gives an IAM user. The console has no form to change the password or import a key. Every other page under `/console/accounts`, the account list included, redirects an IAM-user session to the dashboard.
Sessions expire after 8 hours of inactivity. Click "Logout" to end a session immediately.
diff --git a/docs/manuals/01-architecture-guide.md b/docs/manuals/01-architecture-guide.md
index 412e37d1a..f7b880ed1 100755
--- a/docs/manuals/01-architecture-guide.md
+++ b/docs/manuals/01-architecture-guide.md
@@ -260,7 +260,7 @@ The management web console is a server-rendered HTML interface mounted at `/cons
### Features
- **Login**: Admin users and IAM users (`account_id/user_name`)
-- **Dashboard**: Account and admin user counts (admin sessions only; an IAM-user session gets a link to its own account), version info, quick links
+- **Dashboard**: Account and admin user counts (admin sessions only; an IAM-user session gets a link to its own user page), version info, quick links
- **Account management**: Create, view, delete accounts
- **User management**: Create, delete users; view access keys, policies, tags, group memberships
- **Access key management**: Create and delete access keys (secret shown once at creation)
diff --git a/docs/manuals/05-admin-guide.md b/docs/manuals/05-admin-guide.md
index e51da206c..9fbe0cae4 100755
--- a/docs/manuals/05-admin-guide.md
+++ b/docs/manuals/05-admin-guide.md
@@ -800,12 +800,12 @@ The management web console is served at `/console/` on the same port as the Dyna
### Features
-- **Dashboard**: Account and admin user counts (admin sessions only; an IAM-user session gets a link to its own account), version info
-- **Account management**: Create, view, delete accounts (an IAM-user session views its own account only and is redirected to `/console` from the pages of any other account)
-- **User management**: Create, delete users; view access keys, policies, tags, group memberships
-- **Access key management**: Create and delete access keys (secret shown once)
-- **Group management**: Create, delete groups; add/remove members
-- **Role management**: Create, delete roles; view trust policies
+- **Dashboard**: Account and admin user counts (admin sessions only; an IAM-user session gets a link to its own user page), version info
+- **Account management**: Create, view, delete accounts (admin only; an IAM-user session is redirected to `/console` from the account list and from every account page)
+- **User management**: Create, delete users; view access keys, policies, tags, group memberships (an IAM-user session opens its own user page only, which lists its access keys and leaves out its policies, groups and tags)
+- **Access key management**: Create and delete access keys (secret shown once; an IAM-user session manages its own keys only)
+- **Group management**: Create, delete groups; add/remove members (admin only)
+- **Role management**: Create, delete roles; view trust policies (admin only)
- **Policy management**: Add, delete inline policies with JSON editor
### Authentication
diff --git a/docs/manuals/07-upgrade-manual.md b/docs/manuals/07-upgrade-manual.md
index b1e3295e3..137ae08bb 100755
--- a/docs/manuals/07-upgrade-manual.md
+++ b/docs/manuals/07-upgrade-manual.md
@@ -745,9 +745,23 @@ A policy whose Arn values are literal ARNs, applied to a context value that is a
### v0.1.15: console account pages serve an IAM user its own account only
-**What changed.** Every console page and form under `/console/accounts/{account_id}` now redirects an IAM-user session to `/console` when `{account_id}` is not the session's own account, and the answer is the same whether or not an account has that ID. Before this release the account, user, group and role pages checked only that a console session existed. An IAM user who requested another account's ID read that account's name, the names of its users, groups and roles, each user's access key IDs, inline policy names, groups and tags, and each role's trust policy. The forms under that path already refused IAM users, except the access key forms, which accept an IAM user's own keys. A refused form for another account now redirects to `/console`. Before this release 8 of the 17 forms redirected to a page of that account (the forms that create and delete users, groups and roles, and that add and remove group members), the delete account form redirected to `/console/accounts`, and the 2 access key forms and 6 policy forms redirected to `/console`. The console home page now shows the deployment's account count and admin count to admin sessions only, and shows an IAM-user session a link to its own account.
+**What changed.** Every console page and form under `/console/accounts/{account_id}` now redirects an IAM-user session to `/console` when `{account_id}` is not the session's own account, and the answer is the same whether or not an account has that ID. Before this release the account, user, group and role pages checked only that a console session existed. An IAM user who requested another account's ID read that account's name, the names of its users, groups and roles, each user's access key IDs, inline policy names, groups and tags, and each role's trust policy. The forms under that path already refused IAM users, except the access key forms, which accept an IAM user's own keys. A refused form for another account now redirects to `/console`. Before this release 8 of the 17 forms redirected to a page of that account (the forms that create and delete users, groups and roles, and that add and remove group members), the delete account form redirected to `/console/accounts`, and the 2 access key forms and 6 policy forms redirected to `/console`. The console home page now shows the deployment's account count and admin count to admin sessions only.
-**Who is affected.** IAM users who opened another account's pages in the console. They are redirected to `/console`. The pages of an IAM user's own account and every page an admin session sees are unchanged.
+**Who is affected.** IAM users who opened another account's pages in the console. They are redirected to `/console`. Every page an admin session sees is unchanged.
+
+No catalog migration is required.
+
+---
+
+### v0.1.15: console pages serve an IAM user its own user page and access keys only
+
+**What changed.** In its own account an IAM-user console session now opens only its own user page and the forms that create and delete its own access keys. These are the access key reads and writes the management API gives the same user. The console has no form to change the password or import a key. Every other page under `/console/accounts/{account_id}` redirects the session to `/console`: the account page, the pages of the account's other users, every group page, every role page, and the new user, new group, new role and policy forms. The account list at `/console/accounts` also redirects it, and the navigation bar shows the Accounts link to admin sessions only. Each handler makes its check before it reads the account, user, group or role in the path, so another user's name and a name that no user has get the same answer. Before this release an IAM-user session read every page of its own account. The account page named every user, group and role, a user page showed that user's access key IDs, inline policy names, groups and tags, a group page showed its members and inline policy names, and a role page showed its trust policy, inline policy names and tags. The account list showed the account's name. The management API answered the same reads with HTTP 403.
+
+An IAM user's own user page now lists its access keys, with the forms that create and delete them, and leaves out the user's inline policies, groups and tags, which the management API does not give the user either. The console home page now links an IAM-user session to its own user page instead of the account page.
+
+The forms that change users, groups, roles and policies already refused IAM users. A refused form now redirects to `/console`. Before this release, in an IAM user's own account, the 8 forms that create and delete users, groups and roles and that add and remove group members redirected to the account page or the group page, and the delete account form redirected to `/console/accounts`. The new account form and its submission, `GET` and `POST /console/accounts/new`, also sent an IAM user to `/console/accounts`. The policy forms already redirected to `/console`.
+
+**Who is affected.** IAM users who opened pages of their own account other than their own user page, or who read their own policies, groups or tags on that page. They are redirected to `/console`, or no longer see those sections. Admin sessions see every page as before.
No catalog migration is required.
diff --git a/docs/manuals/10-security-model.md b/docs/manuals/10-security-model.md
index 89da45d36..9a9a5d0a8 100755
--- a/docs/manuals/10-security-model.md
+++ b/docs/manuals/10-security-model.md
@@ -222,7 +222,7 @@ Replace the certificate and key files, then restart extenddb. There is no hot-re
## Web Console Security
-An admin session can read and change every account. Every page and form under `/console/accounts/{account_id}` serves an IAM-user session its own account only. When `{account_id}` is not the session's own account, the handler redirects the session to `/console`. The handler makes this comparison before it looks up the account in the path, so another account's ID and an ID that no account has get the same answer. The accounts list shows an IAM-user session its own account alone, and the console home page shows the deployment's account count and admin count to admin sessions only. In its own account an IAM-user session currently reads every user, group and role page, including other users' access key IDs, policy names and tags, which the management API refuses the same user. Issue #731 tracks this. An IAM-user session can create and delete access keys for its own user, and every other change in the console needs an admin session. Until pull request #727 merges, `/console/metrics-data` serves every console session the table names, index names and traffic metrics of every account, because the metrics table has no account column.
+An admin session can read and change every account. Every page and form under `/console/accounts/{account_id}` serves an IAM-user session its own account only. When `{account_id}` is not the session's own account, the handler redirects the session to `/console`. The handler makes this comparison before it looks up the account in the path, so another account's ID and an ID that no account has get the same answer. The accounts list, and the deployment's account count and admin count on the console home page, are for admin sessions only. In its own account an IAM-user session opens its own user page, which lists its access keys, and creates and deletes its own access keys: the access key reads and writes the management API gives the same user. The console has no form to change the password or import a key. Its own user page leaves out the user's inline policies, groups and tags, which the management API does not give it either. Every other page and form under its account redirects it to `/console`: the account page, the pages of the other users, every group and role page, and every form that changes a user, group, role or policy, its own user and its own policies included. The handler compares the user name in the path with the session's own before it reads anything about that user, so another user's name and a name that no user has get the same answer. Until pull request #727 merges, `/console/metrics-data` serves every console session the table names, index names and traffic metrics of every account, because the metrics table has no account column.
The management web console (`/console/*`) implements:
From 1fc6c2348a642da838563a08c664531be59c6da3 Mon Sep 17 00:00:00 2001
From: Lee Hannigan <29015211+LeeroyHannigan@users.noreply.github.com>
Date: Thu, 5 Nov 2026 02:35:23 +0000
Subject: [PATCH 4/4] test(console): check store reads on refused pages and
CSRF on key forms
account_scope_reads_tests.rs runs the account, group, role and user page
handlers against a catalog store that holds no data and counts every call
to it. An IAM-user session that requests the account page, a group page, a
role page or another user's page gets a redirect to /console (HTTP 303).
The handler makes no call to the store. An admin request and the IAM
user's own user page each make one call.
The integration test posts the create and delete access key forms as the
IAM user. It posts each form twice, once with an empty CSRF token and once
with a wrong token of the same length. Each answer is HTTP 403, and the
user's access keys, as the management API lists them, do not change.
The IAM user in the integration test now belongs to a second group and has
a tag. The test checks that its own page leaves out that group and tag,
and that the admin's view of the same page shows them.
---
.../server/src/console/pages/account_scope.rs | 4 +
.../pages/account_scope_reads_tests.rs | 307 ++++++++++++++++++
tests/test_console_iam_user_pages.py | 73 ++++-
3 files changed, 379 insertions(+), 5 deletions(-)
create mode 100644 crates/server/src/console/pages/account_scope_reads_tests.rs
diff --git a/crates/server/src/console/pages/account_scope.rs b/crates/server/src/console/pages/account_scope.rs
index 56d2e1d29..d7513d57e 100644
--- a/crates/server/src/console/pages/account_scope.rs
+++ b/crates/server/src/console/pages/account_scope.rs
@@ -250,3 +250,7 @@ mod tests {
}
}
}
+
+#[cfg(test)]
+#[path = "account_scope_reads_tests.rs"]
+mod reads_tests;
diff --git a/crates/server/src/console/pages/account_scope_reads_tests.rs b/crates/server/src/console/pages/account_scope_reads_tests.rs
new file mode 100644
index 000000000..6897015a2
--- /dev/null
+++ b/crates/server/src/console/pages/account_scope_reads_tests.rs
@@ -0,0 +1,307 @@
+// Copyright 2026 ExtendDB contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Tests that the handler of a page an IAM-user session may not open
+//! redirects the session before it calls the catalog store.
+//!
+//! The handlers run against a store that holds no data and counts every
+//! call to it. The session lives in the console's in-memory session store,
+//! so a refused request makes no call at all. An admin request and the IAM
+//! user's own user page each make one call, which shows that the store
+//! counts the calls the handlers make.
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::time::Duration;
+
+use axum::extract::{Path, State};
+use axum::http::header::{COOKIE, LOCATION};
+use axum::http::{HeaderMap, HeaderValue, StatusCode};
+use axum::response::Response;
+use extenddb_core::metrics::MetricsCollector;
+use extenddb_storage::CatalogStore;
+use extenddb_storage::authorization_store::{AuthorizationStore, SessionData as RoleSession};
+use extenddb_storage::management_store::{
+ AccessKeyCreated, AccountDetail, AdminEntry, AdminStore, GroupDetail, GroupListEntry,
+ ManagementStore, MetricsRow, MetricsStore, OpError, OpResult, RateLimitStore, RoleDetail,
+ RoleListEntry, SettingsStore, UserDetail, UserListEntry,
+};
+use futures::future::BoxFuture;
+use serde_json::Value;
+use time::OffsetDateTime;
+
+use crate::console::ConsoleState;
+use crate::console::pages::{account_detail, group_detail, role_detail, user_detail};
+use crate::console::session::SessionStore;
+use crate::management::CallerIdentity;
+
+const ACCOUNT: &str = "444455556666";
+/// The name of the user `iam_user` logs in as.
+const OWN_USER: &str = "bob";
+
+/// A catalog store that holds no data and counts every call to it.
+#[derive(Default)]
+struct CountingStore {
+ calls: AtomicUsize,
+}
+
+impl CountingStore {
+ fn calls(&self) -> usize {
+ self.calls.load(Ordering::SeqCst)
+ }
+
+ /// Count a call and answer it with an error. Each page handler renders
+ /// an error from the store as its "not found" page.
+ fn call