test: add comprehensive integration test suite for IAM platform

Add 162 integration tests covering authentication flows, TOTP MFA,
SSH key/certificate management, organization workflows, multi-org
access, self-service features, admin operations, authorization,
security edge cases, department/principal management, CA management,
policy compliance, WebAuthn passkeys, and ZeroTier network access.

Includes:
- Reusable API client library with session management
- Test fixtures for users, organizations, memberships, and CAs
- Helper functions for SSH key generation and verification
- Documentation for running and writing tests

Also update test configuration to disable conflicting maas plugins
and configure WebAuthn/session settings for localhost testing.
This commit is contained in:
2026-04-23 15:41:37 +09:30
parent eb2fc6c8b3
commit 015c622016
36 changed files with 5446 additions and 1 deletions
+53
View File
@@ -0,0 +1,53 @@
"""Admin client for integration tests."""
import logging
logger = logging.getLogger(__name__)
class AdminClient:
"""Wraps admin-only API calls."""
def __init__(self, client):
self._client = client
def list_users(self) -> dict:
"""List all users (paginated)."""
return self._client.get("/admin/users")
def get_user(self, user_id: str) -> dict:
"""Get a single user by ID."""
return self._client.get(f"/admin/users/{user_id}")
def suspend_user(self, user_id: str) -> dict:
"""Suspend a user account."""
return self._client.post(f"/admin/users/{user_id}/suspend")
def unsuspend_user(self, user_id: str) -> dict:
"""Unsuspend a user account."""
return self._client.post(f"/admin/users/{user_id}/unsuspend")
def verify_user_email(self, user_id: str) -> dict:
"""Admin-verify a user's email."""
return self._client.post(f"/admin/users/{user_id}/verify-email")
def set_user_password(self, user_id: str, new_password: str) -> dict:
"""Set a user's password (admin override)."""
return self._client.post(
f"/admin/users/{user_id}/password",
data={"password": new_password},
)
def remove_user_mfa(self, user_id: str, mfa_type: str = "totp") -> dict:
"""Remove a user's MFA method."""
return self._client.delete(f"/admin/users/{user_id}/mfa/{mfa_type}")
def hard_delete_user(self, user_id: str, confirm: bool = False) -> dict:
"""Hard-delete a user."""
return self._client.post(
f"/admin/users/{user_id}/delete",
data={"confirm": confirm},
)
def list_audit_logs(self) -> dict:
"""List system-wide audit logs."""
return self._client.get("/audit-logs")
+125
View File
@@ -0,0 +1,125 @@
"""Auth client for integration tests."""
import logging
logger = logging.getLogger(__name__)
class AuthClient:
"""Wraps authentication-related API calls.
Provides convenience methods for register, login, logout, and
session management. Automatically stores the token on the parent
SecuirdClient when login / register succeed.
"""
def __init__(self, client):
self._client = client
# ------------------------------------------------------------------
# Registration
# ------------------------------------------------------------------
def register(self, email: str, password: str, full_name: str | None = None) -> dict:
"""Register a new user and return the response payload.
Args:
email: User's email address.
password: Plain-text password (>= 8 chars).
full_name: Optional display name.
Returns:
API response dict containing ``user``, ``token``, ``expires_at``.
Raises:
ApiError: On validation failure or duplicate email.
"""
logger.info(f"[AuthClient] Registering user: email={email}")
payload = {"email": email, "password": password, "password_confirm": password}
if full_name:
payload["full_name"] = full_name
result = self._client.post("/auth/register", data=payload)
token = result.get("data", {}).get("token")
if token:
self._client.set_token(token)
logger.info(f"[AuthClient] Registration successful — token stored")
return result
# ------------------------------------------------------------------
# Login / Logout
# ------------------------------------------------------------------
def login(self, email: str, password: str, remember_me: bool = False) -> dict:
"""Authenticate with email and password.
Args:
email: Registered email address.
password: Plain-text password.
remember_me: Request a long-lived session.
Returns:
API response dict. If TOTP / WebAuthn is required the
response contains ``requires_totp`` or ``requires_webauthn``
instead of a token.
"""
logger.info(f"[AuthClient] Logging in: email={email}")
result = self._client.post(
"/auth/login",
data={"email": email, "password": password, "remember_me": remember_me},
)
token = result.get("data", {}).get("token")
if token:
self._client.set_token(token)
logger.info(f"[AuthClient] Login successful — token stored")
return result
def logout(self) -> dict:
"""Log out the current user and clear the stored token."""
logger.info("[AuthClient] Logging out")
result = self._client.post("/auth/logout")
self._client.clear_token()
return result
# ------------------------------------------------------------------
# Current user
# ------------------------------------------------------------------
def me(self) -> dict:
"""Return the current authenticated user's profile."""
return self._client.get("/auth/me")
# ------------------------------------------------------------------
# Sessions
# ------------------------------------------------------------------
def list_sessions(self) -> dict:
"""Return active sessions for the current user."""
return self._client.get("/auth/sessions")
def revoke_session(self, session_id: str) -> dict:
"""Revoke a specific session belonging to the current user."""
return self._client.delete(f"/auth/sessions/{session_id}")
# ------------------------------------------------------------------
# Password recovery
# ------------------------------------------------------------------
def forgot_password(self, email: str) -> dict:
"""Request a password-reset email."""
return self._client.post("/auth/forgot-password", data={"email": email})
def reset_password(self, token: str, new_password: str, new_password_confirm: str) -> dict:
"""Reset password using a token from the forgot-password flow."""
return self._client.post(
"/auth/reset-password",
data={
"token": token,
"password": new_password,
"password_confirm": new_password_confirm,
},
)
# ------------------------------------------------------------------
# Email verification
# ------------------------------------------------------------------
def verify_email(self, token: str) -> dict:
"""Verify an email address using the token sent by email."""
return self._client.post("/auth/verify-email", data={"token": token})
def resend_verification(self, email: str) -> dict:
"""Re-send the verification email."""
return self._client.post("/auth/resend-verification", data={"email": email})
+189
View File
@@ -0,0 +1,189 @@
"""Base HTTP client for integration testing."""
import json
import logging
logger = logging.getLogger(__name__)
class ApiError(Exception):
"""Detailed exception for API call failures.
Attributes:
message: Human-readable error message from the API.
status_code: HTTP status code returned.
error_type: Machine-readable error type string (e.g. VALIDATION_ERROR).
error_details: Optional dict with field-level validation errors.
url: The full API route that was called.
method: The HTTP method used.
response_data: The complete parsed JSON response body.
"""
def __init__(
self,
*,
message: str,
status_code: int,
error_type: str,
error_details: dict | None,
url: str,
method: str,
response_data: dict,
):
self.message = message
self.status_code = status_code
self.error_type = error_type
self.error_details = error_details or {}
self.url = url
self.method = method
self.response_data = response_data
super().__init__(self._build_message())
def _build_message(self) -> str:
lines = [
f"",
f"{'='*60}",
f" API ERROR: {self.method.upper()} {self.url}",
f" Status: {self.status_code}",
f" Error Type: {self.error_type}",
f" Message: {self.message}",
]
if self.error_details:
lines.append(f" Details: {self.error_details}")
lines.append(f" Full Response: {self.response_data}")
lines.append(f"{'='*60}")
return "\n".join(lines)
def __str__(self) -> str:
return self._build_message()
class SecuirdClient:
"""Stateful CLI-style test client for Secuird API.
Wraps Flask's ``test_client`` and manages auth tokens, JSON
serialization, and detailed error reporting so tests fail with
actionable output.
"""
def __init__(self, flask_test_client):
self._client = flask_test_client
self._token: str | None = None
logger.debug("[SecuirdClient] Initialized")
# Attach domain-specific sub-clients
from tests.integration.client.auth import AuthClient
from tests.integration.client.mfa import MfaClient
from tests.integration.client.ssh import SshClient
from tests.integration.client.orgs import OrgsClient
from tests.integration.client.admin import AdminClient
from tests.integration.client.users import UsersClient
self.auth = AuthClient(self)
self.mfa = MfaClient(self)
self.ssh = SshClient(self)
self.orgs = OrgsClient(self)
self.admin = AdminClient(self)
self.users = UsersClient(self)
def set_token(self, token: str) -> None:
"""Store a Bearer token for subsequent requests."""
self._token = token
logger.debug(f"[SecuirdClient] Token set: {token[:12]}...")
def clear_token(self) -> None:
"""Remove the stored Bearer token."""
self._token = None
logger.debug("[SecuirdClient] Token cleared")
def _url(self, path: str) -> str:
"""Ensure the path starts with /api/v1."""
if path.startswith("http"):
return path
if not path.startswith("/api/v1"):
path = f"/api/v1{path}"
return path
def _headers(self) -> dict:
"""Build request headers including auth if available."""
headers = {"Accept": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
return headers
def _request(self, method: str, path: str, data: dict | None = None) -> dict:
"""Execute an HTTP request and handle the response.
Args:
method: HTTP method (get, post, patch, delete).
path: API path (e.g. /auth/register).
data: Optional JSON-serializable payload.
Returns:
The parsed JSON response body.
Raises:
ApiError: If the response status code is >= 400.
"""
url = self._url(path)
headers = self._headers()
kwargs = {"headers": headers, "follow_redirects": True}
if data is not None and method in ("post", "patch", "delete"):
headers["Content-Type"] = "application/json"
kwargs["data"] = json.dumps(data)
logger.debug(f"[SecuirdClient] {method.upper()} {url} — data={data}")
response = getattr(self._client, method)(url, **kwargs)
try:
body = response.get_json()
except Exception:
body = {"_raw": response.data.decode("utf-8", errors="replace")}
logger.debug(f"[SecuirdClient] {method.upper()} {url} — status={response.status_code}")
if response.status_code >= 400:
# The API may return error info nested under `error` or flat at top level
error_block = body.get("error") if isinstance(body.get("error"), dict) else {}
error_type = (
error_block.get("type")
or body.get("error_type", "UNKNOWN_ERROR")
if body else "UNKNOWN_ERROR"
)
error_details = (
error_block.get("details")
or body.get("error_details")
if body else None
)
message = body.get("message", "No message provided") if body else "No message provided"
raise ApiError(
message=message,
status_code=response.status_code,
error_type=error_type,
error_details=error_details,
url=url,
method=method.upper(),
response_data=body or {},
)
return body or {}
def get(self, path: str) -> dict:
"""Execute a GET request."""
return self._request("get", path)
def post(self, path: str, data: dict | None = None) -> dict:
"""Execute a POST request."""
return self._request("post", path, data)
def patch(self, path: str, data: dict | None = None) -> dict:
"""Execute a PATCH request."""
return self._request("patch", path, data)
def put(self, path: str, data: dict | None = None) -> dict:
"""Execute a PUT request."""
return self._request("put", path, data)
def delete(self, path: str, data: dict | None = None) -> dict:
"""Execute a DELETE request."""
return self._request("delete", path, data)
+95
View File
@@ -0,0 +1,95 @@
"""MFA (TOTP) client for integration tests."""
import logging
logger = logging.getLogger(__name__)
class MfaClient:
"""Wraps TOTP MFA-related API calls."""
def __init__(self, client):
self._client = client
# ------------------------------------------------------------------
# TOTP Enrollment
# ------------------------------------------------------------------
def enroll_totp(self) -> dict:
"""Begin TOTP enrollment.
Returns:
Response dict containing ``secret``, ``provisioning_uri``,
``qr_code``, and ``backup_codes``.
"""
logger.info("[MfaClient] Enrolling TOTP")
return self._client.post("/auth/totp/enroll")
def verify_enrollment(self, code: str, client_timestamp: str | None = None) -> dict:
"""Complete TOTP enrollment by verifying the first code.
Args:
code: 6-digit TOTP code generated from the secret.
client_timestamp: Optional ISO-8601 timestamp for drift calc.
"""
payload = {"code": code}
if client_timestamp:
payload["client_timestamp"] = client_timestamp
logger.info("[MfaClient] Verifying TOTP enrollment")
return self._client.post("/auth/totp/verify-enrollment", data=payload)
# ------------------------------------------------------------------
# TOTP Verification (during login)
# ------------------------------------------------------------------
def verify_totp(self, code: str, is_backup_code: bool = False, client_timestamp: str | None = None) -> dict:
"""Verify TOTP code during the multi-step login flow.
This is called AFTER ``AuthClient.login`` returns
``requires_totp=True`` and stores the pending user id in the
server-side session.
Args:
code: 6-digit TOTP code or backup code.
is_backup_code: True if ``code`` is a backup code.
client_timestamp: Optional ISO-8601 timestamp.
Returns:
Response dict containing ``user``, ``token``, ``expires_at``.
"""
payload = {"code": code, "is_backup_code": is_backup_code}
if client_timestamp:
payload["client_timestamp"] = client_timestamp
logger.info(f"[MfaClient] Verifying TOTP — backup={is_backup_code}")
result = self._client.post("/auth/totp/verify", data=payload)
token = result.get("data", {}).get("token")
if token:
self._client.set_token(token)
logger.info("[MfaClient] TOTP verification successful — token stored")
return result
# ------------------------------------------------------------------
# TOTP Management
# ------------------------------------------------------------------
def get_totp_status(self) -> dict:
"""Return current TOTP status and remaining backup codes."""
return self._client.get("/auth/totp/status")
def disable_totp(self, password: str) -> dict:
"""Disable TOTP for the current user.
Args:
password: Current account password (required for confirmation).
"""
return self._client.delete("/auth/totp/disable", data={"password": password})
def regenerate_backup_codes(self, password: str) -> dict:
"""Generate a fresh set of backup codes.
Args:
password: Current account password (required for confirmation).
Returns:
Response dict containing ``backup_codes``.
"""
return self._client.post(
"/auth/totp/regenerate-backup-codes",
data={"password": password},
)
+191
View File
@@ -0,0 +1,191 @@
"""Organization client for integration tests."""
import logging
logger = logging.getLogger(__name__)
class OrgsClient:
"""Wraps organization-related API calls."""
def __init__(self, client):
self._client = client
# ------------------------------------------------------------------
# Organization CRUD
# ------------------------------------------------------------------
def create(self, name: str, slug: str | None = None, description: str | None = None) -> dict:
"""Create a new organization."""
payload: dict = {"name": name}
if slug:
payload["slug"] = slug
if description:
payload["description"] = description
return self._client.post("/organizations", data=payload)
def get(self, org_id: str) -> dict:
"""Get organization details."""
return self._client.get(f"/organizations/{org_id}")
def update(self, org_id: str, **fields) -> dict:
"""Update organization fields (name, description, etc.)."""
return self._client.patch(f"/organizations/{org_id}", data=fields)
def delete(self, org_id: str, confirm: bool = False) -> dict:
"""Delete (soft-delete) an organization."""
return self._client.delete(f"/organizations/{org_id}", data={"confirm": confirm})
# ------------------------------------------------------------------
# Members
# ------------------------------------------------------------------
def list_members(self, org_id: str) -> dict:
"""List members of an organization."""
return self._client.get(f"/organizations/{org_id}/members")
def add_member(self, org_id: str, email: str, role: str = "member") -> dict:
"""Add an existing user as a member."""
return self._client.post(
f"/organizations/{org_id}/members",
data={"email": email, "role": role},
)
def remove_member(self, org_id: str, member_id: str) -> dict:
"""Remove a member from an organization."""
return self._client.delete(f"/organizations/{org_id}/members/{member_id}")
def update_member_role(self, org_id: str, member_id: str, role: str) -> dict:
"""Update a member's role."""
return self._client.patch(
f"/organizations/{org_id}/members/{member_id}/role",
data={"role": role},
)
def transfer_ownership(self, org_id: str, new_owner_id: str) -> dict:
"""Transfer organization ownership."""
return self._client.post(
f"/organizations/{org_id}/transfer-ownership",
data={"new_owner_user_id": new_owner_id},
)
# ------------------------------------------------------------------
# Invites
# ------------------------------------------------------------------
def list_invites(self, org_id: str) -> dict:
"""List pending invites."""
return self._client.get(f"/organizations/{org_id}/invites")
def create_invite(self, org_id: str, email: str, role: str = "member") -> dict:
"""Create an invite for a new user."""
return self._client.post(
f"/organizations/{org_id}/invites",
data={"email": email, "role": role},
)
def cancel_invite(self, org_id: str, invite_id: str) -> dict:
"""Cancel a pending invite."""
return self._client.delete(f"/organizations/{org_id}/invites/{invite_id}")
def get_invite_by_token(self, token: str) -> dict:
"""Get invite info by token (public endpoint)."""
return self._client.get(f"/invites/{token}")
def accept_invite(self, token: str, password: str | None = None, full_name: str | None = None, password_confirm: str | None = None) -> dict:
"""Accept an invite. For new users, password and full_name are required."""
payload: dict = {}
if password:
payload["password"] = password
if password_confirm:
payload["password_confirm"] = password_confirm
if full_name:
payload["full_name"] = full_name
result = self._client.post(f"/invites/{token}/accept", data=payload)
# Store token if returned (new user registration)
token_val = result.get("data", {}).get("token")
if token_val:
self._client.set_token(token_val)
return result
# ------------------------------------------------------------------
# Principals & Departments
# ------------------------------------------------------------------
def list_principals(self, org_id: str) -> dict:
"""List principals in an organization."""
return self._client.get(f"/organizations/{org_id}/principals")
def create_principal(self, org_id: str, name: str, description: str | None = None) -> dict:
"""Create a principal."""
payload: dict = {"name": name}
if description:
payload["description"] = description
return self._client.post(f"/organizations/{org_id}/principals", data=payload)
def add_principal_member(self, org_id: str, principal_id: str, email: str) -> dict:
"""Add a user to a principal."""
return self._client.post(
f"/organizations/{org_id}/principals/{principal_id}/members",
data={"email": email},
)
def list_departments(self, org_id: str) -> dict:
"""List departments in an organization."""
return self._client.get(f"/organizations/{org_id}/departments")
def create_department(self, org_id: str, name: str, description: str | None = None) -> dict:
"""Create a department."""
payload: dict = {"name": name}
if description:
payload["description"] = description
return self._client.post(f"/organizations/{org_id}/departments", data=payload)
def add_department_member(self, org_id: str, dept_id: str, email: str) -> dict:
"""Add a user to a department."""
return self._client.post(
f"/organizations/{org_id}/departments/{dept_id}/members",
data={"email": email},
)
def link_principal_department(self, org_id: str, principal_id: str, dept_id: str) -> dict:
"""Link a principal to a department."""
return self._client.post(
f"/organizations/{org_id}/principals/{principal_id}/departments/{dept_id}",
data={},
)
# ------------------------------------------------------------------
# CAs
# ------------------------------------------------------------------
def list_cas(self, org_id: str) -> dict:
"""List CAs for an organization."""
return self._client.get(f"/organizations/{org_id}/cas")
def create_ca(self, org_id: str, name: str, ca_type: str = "user", key_type: str = "ed25519") -> dict:
"""Create a Certificate Authority."""
return self._client.post(
f"/organizations/{org_id}/cas",
data={"name": name, "ca_type": ca_type, "key_type": key_type},
)
def get_ca(self, org_id: str, ca_id: str) -> dict:
"""Get a CA by ID."""
return self._client.get(f"/organizations/{org_id}/cas/{ca_id}")
def rotate_ca(self, org_id: str, ca_id: str) -> dict:
"""Rotate a CA key."""
return self._client.post(f"/organizations/{org_id}/cas/{ca_id}/rotate")
# ------------------------------------------------------------------
# API Keys
# ------------------------------------------------------------------
def list_api_keys(self, org_id: str) -> dict:
"""List API keys."""
return self._client.get(f"/organizations/{org_id}/api-keys")
def create_api_key(self, org_id: str, name: str, role: str = "member") -> dict:
"""Create an API key."""
return self._client.post(
f"/organizations/{org_id}/api-keys",
data={"name": name, "role": role},
)
def revoke_api_key(self, org_id: str, key_id: str) -> dict:
"""Revoke an API key."""
return self._client.delete(f"/organizations/{org_id}/api-keys/{key_id}")
+132
View File
@@ -0,0 +1,132 @@
"""SSH client for integration tests."""
import logging
logger = logging.getLogger(__name__)
class SshClient:
"""Wraps SSH key and certificate API calls."""
def __init__(self, client):
self._client = client
# ------------------------------------------------------------------
# SSH Key Management
# ------------------------------------------------------------------
def list_keys(self) -> dict:
"""Return all SSH keys belonging to the current user."""
return self._client.get("/ssh/keys")
def add_key(self, public_key: str, description: str | None = None) -> dict:
"""Upload a new SSH public key.
Args:
public_key: The OpenSSH-format public key string.
description: Optional human-readable label.
"""
payload = {"public_key": public_key}
if description:
payload["description"] = description
logger.info("[SshClient] Adding SSH key")
return self._client.post("/ssh/keys", data=payload)
def get_key(self, key_id: str) -> dict:
"""Return a single SSH key by ID."""
return self._client.get(f"/ssh/keys/{key_id}")
def delete_key(self, key_id: str) -> dict:
"""Delete an SSH key."""
return self._client.delete(f"/ssh/keys/{key_id}")
def update_description(self, key_id: str, description: str) -> dict:
"""Update the description of an SSH key."""
return self._client.patch(
f"/ssh/keys/{key_id}/update-description",
data={"description": description},
)
# ------------------------------------------------------------------
# SSH Key Verification
# ------------------------------------------------------------------
def get_challenge(self, key_id: str) -> dict:
"""Generate a verification challenge for an SSH key.
Returns:
Response dict containing ``challenge_text``.
"""
return self._client.get(f"/ssh/keys/{key_id}/verify")
def verify_key(self, key_id: str, signature: str) -> dict:
"""Verify ownership of an SSH key by submitting a signature.
Args:
key_id: The SSH key ID.
signature: Base64-encoded signature of the challenge text.
"""
return self._client.post(
f"/ssh/keys/{key_id}/verify",
data={"action": "verify_signature", "signature": signature},
)
# ------------------------------------------------------------------
# SSH Certificate Signing
# ------------------------------------------------------------------
def sign_certificate(
self,
*,
key_id: str | None = None,
principals: list[str] | None = None,
cert_type: str = "user",
expiry_hours: int | None = None,
) -> dict:
"""Request an SSH user certificate.
Args:
key_id: SSH key to attach the certificate to.
principals: Optional list of requested principals.
cert_type: "user" or "host".
expiry_hours: Optional custom expiry within policy.
"""
payload: dict = {"cert_type": cert_type}
if key_id:
payload["key_id"] = key_id
if principals:
payload["principals"] = principals
if expiry_hours:
payload["expiry_hours"] = expiry_hours
logger.info(f"[SshClient] Signing certificate — type={cert_type}")
return self._client.post("/ssh/sign", data=payload)
def sign_host_certificate(self, *, host_public_key: str, ca_id: str | None = None) -> dict:
"""Request an SSH host certificate (admin-only).
Args:
host_public_key: The host's public key material.
ca_id: Optional CA ID (defaults to org's host CA).
"""
payload: dict = {"host_public_key": host_public_key, "cert_type": "host"}
if ca_id:
payload["ca_id"] = ca_id
return self._client.post("/ssh/sign/host", data=payload)
# ------------------------------------------------------------------
# Certificate Management
# ------------------------------------------------------------------
def list_certificates(self) -> dict:
"""Return all certificates for the current user."""
return self._client.get("/ssh/certificates")
def get_certificate(self, cert_id: str) -> dict:
"""Return a single certificate by ID."""
return self._client.get(f"/ssh/certificates/{cert_id}")
def revoke_certificate(self, cert_id: str, reason: str = "User revoked") -> dict:
"""Revoke a certificate."""
return self._client.post(
f"/ssh/certificates/{cert_id}/revoke",
data={"reason": reason},
)
def get_ca_public_key(self) -> dict:
"""Return the organization's CA public key."""
return self._client.get("/ssh/ca/public-key")
+50
View File
@@ -0,0 +1,50 @@
"""Users (self-service) client for integration tests."""
import logging
logger = logging.getLogger(__name__)
class UsersClient:
"""Wraps user self-service API calls."""
def __init__(self, client):
self._client = client
def get_profile(self) -> dict:
"""Get the current user's profile."""
return self._client.get("/users/me")
def update_profile(self, **fields) -> dict:
"""Update profile fields (full_name, avatar_url)."""
return self._client.patch("/users/me", data=fields)
def change_password(self, current_password: str, new_password: str, new_password_confirm: str) -> dict:
"""Change the current user's password."""
return self._client.post(
"/users/me/password",
data={
"current_password": current_password,
"new_password": new_password,
"new_password_confirm": new_password_confirm,
},
)
def delete_account(self) -> dict:
"""Soft-delete the current user's account."""
return self._client.delete("/users/me")
def get_my_organizations(self) -> dict:
"""List organizations the current user belongs to."""
return self._client.get("/users/me/organizations")
def get_my_memberships(self) -> dict:
"""List detailed memberships across orgs."""
return self._client.get("/users/me/memberships")
def get_my_principals(self) -> dict:
"""List principals the current user has access to."""
return self._client.get("/users/me/principals")
def get_my_invites(self) -> dict:
"""List pending invites for the current user."""
return self._client.get("/users/me/invites")