touchque-authenticator 4.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. touchque/__init__.py +148 -0
  2. touchque/agent.py +314 -0
  3. touchque/agent_card.py +190 -0
  4. touchque/agent_frameworks.py +215 -0
  5. touchque/approval_proof.py +104 -0
  6. touchque/approval_token.py +134 -0
  7. touchque/client.py +105 -0
  8. touchque/code_reply.py +75 -0
  9. touchque/config.py +56 -0
  10. touchque/contrib/__init__.py +4 -0
  11. touchque/contrib/django.py +175 -0
  12. touchque/contrib/fastapi.py +208 -0
  13. touchque/contrib/flask.py +154 -0
  14. touchque/cross_app_access.py +222 -0
  15. touchque/exceptions.py +170 -0
  16. touchque/guard.py +290 -0
  17. touchque/hosted_login.py +474 -0
  18. touchque/http_client.py +156 -0
  19. touchque/mcp_auth.py +256 -0
  20. touchque/my_account.py +173 -0
  21. touchque/org_agent.py +262 -0
  22. touchque/rate_limiter.py +56 -0
  23. touchque/relay.py +643 -0
  24. touchque/resources/__init__.py +1 -0
  25. touchque/resources/action_types.py +52 -0
  26. touchque/resources/actions.py +234 -0
  27. touchque/resources/agents.py +236 -0
  28. touchque/resources/assist.py +115 -0
  29. touchque/resources/auth.py +65 -0
  30. touchque/resources/device_risk.py +174 -0
  31. touchque/resources/identity.py +268 -0
  32. touchque/resources/login.py +296 -0
  33. touchque/resources/mobile.py +130 -0
  34. touchque/resources/offline.py +138 -0
  35. touchque/resources/qr_pairings.py +93 -0
  36. touchque/resources/recovery.py +118 -0
  37. touchque/resources/risk.py +141 -0
  38. touchque/resources/safe_guard.py +99 -0
  39. touchque/resources/webauthn.py +115 -0
  40. touchque/resources/webhook.py +258 -0
  41. touchque/safe_guard.py +308 -0
  42. touchque/security_events.py +205 -0
  43. touchque/steps.py +239 -0
  44. touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
  45. touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
  46. touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
  47. touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
  48. touchque_authenticator-4.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,52 @@
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from ..http_client import HttpClient
4
+
5
+ # The Second Thought questions an action type can ask (at most two).
6
+ SECOND_THOUGHT_QUESTIONS = (
7
+ 'someone_guiding',
8
+ 'safe_account',
9
+ 'remote_access',
10
+ 'asked_for_code',
11
+ 'support_call',
12
+ 'met_online',
13
+ 'promised_return',
14
+ 'know_recipient',
15
+ 'sim_in_hand',
16
+ )
17
+
18
+ _KEEP: Any = object()
19
+
20
+
21
+ class ActionTypes:
22
+ """Action types ("LOGIN", "SEND_MONEY", …) — what a user is asked to
23
+ approve. Define them from code at start-up instead of clicking them
24
+ into the Dashboard. (Before 4.0 this was ``tq.actions``; ``tq.actions``
25
+ is TouchQue Actions now.)"""
26
+
27
+ def __init__(self, http: HttpClient):
28
+ self._http = http
29
+
30
+ def define(self, type: str, name: Optional[str] = None, description: Optional[str] = None,
31
+ critical: Optional[bool] = None, second_thought: Optional[Dict[str, Any]] = _KEEP) -> Dict[str, Any]:
32
+ """Creates the action type, or updates its name, description,
33
+ critical flag and Second Thought questions. Safe to call on every
34
+ start-up: it never re-enables a type an admin disabled.
35
+
36
+ ``second_thought``: ``{'questions': [...], 'locale': 'tr'|'en'}``, at
37
+ most two of ``SECOND_THOUGHT_QUESTIONS``, asked on the phone before
38
+ Approve. A risky answer never refuses: the approval comes back
39
+ ``provisional`` and the phone asks "Was this you?" later. ``None``
40
+ turns them off; leave it out to keep what the Dashboard has.
41
+ """
42
+ body: Dict[str, Any] = {'type': type, 'name': name or type}
43
+ if description is not None:
44
+ body['description'] = description
45
+ if critical is not None:
46
+ body['critical'] = critical
47
+ if second_thought is not _KEEP:
48
+ body['secondThought'] = second_thought
49
+ return self._http.post('/action-types', body)
50
+
51
+ def list(self) -> List[Dict[str, Any]]:
52
+ return self._http.get('/action-types')
@@ -0,0 +1,234 @@
1
+ """TouchQue Actions: your own code at points of the approval pipeline (before
2
+ the push is sent, the silent sign-in decision, after an approval, fraud
3
+ reports, device and recovery events). TouchQue runs it in a sandbox; this
4
+ resource deploys, tests, replays and observes it::
5
+
6
+ tq.actions.deploy(
7
+ 'night-alert',
8
+ trigger='approval.decided',
9
+ template={'id': 'alert', 'params': {'channel': 'slack', 'when': {'countries': ['TR'], 'hours': {'from': 0, 'to': 6}}}},
10
+ )
11
+
12
+ Action code itself is JavaScript (``exports.onExecute = async (event, api) => {...}``),
13
+ whatever language your server is written in.
14
+ """
15
+ import re
16
+ from datetime import datetime
17
+ from typing import Any, Dict, List, Optional, Union
18
+
19
+ from ..exceptions import TouchQueConfigException
20
+ from ..http_client import HttpClient
21
+
22
+ _SLUG_RE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
23
+ _SECRET_RE = re.compile(r'^[A-Z][A-Z0-9_]{0,63}$')
24
+ _TEMPLATE_RE = re.compile(r'^[a-z_]{1,64}$')
25
+
26
+
27
+ def _slug_path(slug: str) -> str:
28
+ if not isinstance(slug, str) or not _SLUG_RE.match(slug):
29
+ raise TouchQueConfigException('An Action slug is 2-63 lowercase letters, digits or - (e.g. "night-alert")')
30
+ return f'/actions/{slug}'
31
+
32
+
33
+ def _secret_path(name: str) -> str:
34
+ if not isinstance(name, str) or not _SECRET_RE.match(name):
35
+ raise TouchQueConfigException('A secret name is capital letters, digits or _ (e.g. SLACK_WEBHOOK_URL)')
36
+ return f'/actions/secrets/{name}'
37
+
38
+
39
+ def _draft(trigger=None, code=None, template=None, slug=None, secrets=None, allowed_hosts=None,
40
+ capabilities=None) -> Dict[str, Any]:
41
+ body: Dict[str, Any] = {}
42
+ if trigger is not None:
43
+ body['trigger'] = trigger
44
+ if slug is not None:
45
+ body['slug'] = slug
46
+ if code is not None:
47
+ body['code'] = code
48
+ if template is not None:
49
+ body['template'] = template
50
+ if secrets is not None:
51
+ body['secrets'] = list(secrets)
52
+ if allowed_hosts is not None:
53
+ body['allowedHosts'] = list(allowed_hosts)
54
+ if capabilities is not None:
55
+ body['capabilities'] = list(capabilities)
56
+ return body
57
+
58
+
59
+ def _iso(value: Union[str, datetime, None]) -> Optional[str]:
60
+ if isinstance(value, datetime):
61
+ return value.isoformat()
62
+ return value
63
+
64
+
65
+ class ActionSecrets:
66
+ """Secrets your Actions read (``event.secrets.NAME``) or notify with. A
67
+ value is encrypted and never readable again through the API."""
68
+
69
+ def __init__(self, http: HttpClient):
70
+ self._http = http
71
+
72
+ def set(self, name: str, value: str) -> Dict[str, Any]:
73
+ if not isinstance(value, str) or not value:
74
+ raise TouchQueConfigException('A secret value is a non-empty string')
75
+ return self._http.put(_secret_path(name), {'value': value})
76
+
77
+ def list(self) -> List[Dict[str, Any]]:
78
+ """Names only."""
79
+ res = self._http.get('/actions/secrets')
80
+ return res.get('secrets', []) if isinstance(res, dict) else []
81
+
82
+ def delete(self, name: str) -> Dict[str, Any]:
83
+ return self._http.delete(_secret_path(name))
84
+
85
+
86
+ class Actions:
87
+ """TouchQue Actions management: deploy, roll back, test, dry run, logs,
88
+ metrics, secrets and no-code templates."""
89
+
90
+ def __init__(self, http: HttpClient):
91
+ self._http = http
92
+ self.secrets = ActionSecrets(http)
93
+
94
+ def deploy(self, slug: str, trigger: Optional[str] = None, code: Optional[str] = None,
95
+ template: Optional[Dict[str, Any]] = None, name: Optional[str] = None,
96
+ secrets: Optional[List[str]] = None, allowed_hosts: Optional[List[str]] = None,
97
+ capabilities: Optional[List[str]] = None, on_failure: Optional[str] = None,
98
+ order: Optional[int] = None, enabled: Optional[bool] = None,
99
+ activate: Optional[bool] = None) -> Dict[str, Any]:
100
+ """Creates the Action or adds a version and makes it live (idempotent:
101
+ the same code and settings change nothing). A version asking for a
102
+ loosening capability (``skip_number_match``, ``override_block``,
103
+ ``allow_silent``) waits for an admin's phone approval: the answer then
104
+ has ``deployed: False`` and ``approval['required']``.
105
+
106
+ :param trigger: required for a new Action (``login.request``,
107
+ ``risk.check``, ``agent.approval``, ``approval.decided``, …); cannot change later.
108
+ :param code: JavaScript exporting ``onExecute``.
109
+ :param template: ``{'id': 'alert' | 'block_or_step_up' | ..., 'params': {...}}``
110
+ instead of code.
111
+ :param on_failure: pre triggers only: ``continue`` (default, never
112
+ blocks), ``step_up`` or ``deny`` when the Action errors or times out.
113
+ :param activate: ``False`` saves the version without making it live.
114
+ """
115
+ if code is None and template is None:
116
+ raise TouchQueConfigException('deploy() needs code or template')
117
+ body = _draft(trigger=trigger, code=code, template=template, secrets=secrets,
118
+ allowed_hosts=allowed_hosts, capabilities=capabilities)
119
+ if name is not None:
120
+ body['name'] = name
121
+ if on_failure is not None:
122
+ body['onFailure'] = on_failure
123
+ if order is not None:
124
+ body['order'] = order
125
+ if enabled is not None:
126
+ body['enabled'] = enabled
127
+ if activate is not None:
128
+ body['activate'] = activate
129
+ return self._http.put(_slug_path(slug), body)
130
+
131
+ def list(self) -> List[Dict[str, Any]]:
132
+ res = self._http.get('/actions')
133
+ return res.get('actions', []) if isinstance(res, dict) else []
134
+
135
+ def get(self, slug: str) -> Dict[str, Any]:
136
+ """The Action, the active version's code and its versions."""
137
+ return self._http.get(_slug_path(slug))
138
+
139
+ def version(self, slug: str, number: int) -> Dict[str, Any]:
140
+ """One version, with its code."""
141
+ if not isinstance(number, int):
142
+ raise TouchQueConfigException('version() needs a version number')
143
+ return self._http.get(f'{_slug_path(slug)}/versions/{number}')
144
+
145
+ def update(self, slug: str, name: Optional[str] = None, enabled: Optional[bool] = None,
146
+ order: Optional[int] = None, on_failure: Optional[str] = None) -> Dict[str, Any]:
147
+ """Rename, turn on or off, reorder, change on_failure."""
148
+ body: Dict[str, Any] = {}
149
+ if name is not None:
150
+ body['name'] = name
151
+ if enabled is not None:
152
+ body['enabled'] = enabled
153
+ if order is not None:
154
+ body['order'] = order
155
+ if on_failure is not None:
156
+ body['onFailure'] = on_failure
157
+ return self._http.patch(_slug_path(slug), body).get('action')
158
+
159
+ def delete(self, slug: str) -> Dict[str, Any]:
160
+ return self._http.delete(_slug_path(slug))
161
+
162
+ def rollback(self, slug: str, version: Optional[int] = None) -> Dict[str, Any]:
163
+ """Back to ``version``, or to the newest runnable version before the
164
+ active one. Takes effect at once."""
165
+ return self._http.post(f'{_slug_path(slug)}/rollback', {} if version is None else {'version': version})
166
+
167
+ def test(self, trigger: Optional[str] = None, code: Optional[str] = None,
168
+ template: Optional[Dict[str, Any]] = None, slug: Optional[str] = None,
169
+ event_id: Optional[str] = None, event: Optional[Dict[str, Any]] = None,
170
+ secrets: Optional[List[str]] = None, allowed_hosts: Optional[List[str]] = None,
171
+ capabilities: Optional[List[str]] = None, network: Optional[bool] = None) -> Dict[str, Any]:
172
+ """Runs a draft (or the deployed Action ``slug``) once on a recorded
173
+ event (``event_id``), your own ``event`` or the trigger's sample.
174
+ Sends no notification."""
175
+ body = _draft(trigger=trigger, code=code, template=template, slug=slug, secrets=secrets,
176
+ allowed_hosts=allowed_hosts, capabilities=capabilities)
177
+ if event_id is not None:
178
+ body['eventId'] = event_id
179
+ if event is not None:
180
+ body['event'] = event
181
+ if network is not None:
182
+ body['network'] = network
183
+ return self._http.post('/actions/test', body)
184
+
185
+ def dry_run(self, trigger: Optional[str] = None, code: Optional[str] = None,
186
+ template: Optional[Dict[str, Any]] = None, slug: Optional[str] = None,
187
+ replaces: Optional[str] = None, limit: Optional[int] = None,
188
+ since: Union[str, datetime, None] = None, on_failure: Optional[str] = None,
189
+ secrets: Optional[List[str]] = None, allowed_hosts: Optional[List[str]] = None,
190
+ capabilities: Optional[List[str]] = None) -> Dict[str, Any]:
191
+ """Replays a draft against recent real decisions: how many it would
192
+ have changed (``changed``, ``transitions`` such as
193
+ ``{'push→number_match': 12}``), notifications it would have sent,
194
+ errors and timing. ``replaces``: the deployed Action it would replace."""
195
+ body = _draft(trigger=trigger, code=code, template=template, slug=slug, secrets=secrets,
196
+ allowed_hosts=allowed_hosts, capabilities=capabilities)
197
+ if replaces is not None:
198
+ body['replaces'] = replaces
199
+ if on_failure is not None:
200
+ body['onFailure'] = on_failure
201
+ if limit is not None:
202
+ body['limit'] = limit
203
+ if since is not None:
204
+ body['since'] = _iso(since)
205
+ return self._http.post('/actions/dry-run', body)
206
+
207
+ def events(self, trigger: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]:
208
+ """Recorded events (newest first, kept 30 days); use an id with test()."""
209
+ res = self._http.get('/actions/events', {'trigger': trigger, 'limit': limit})
210
+ return res.get('events', []) if isinstance(res, dict) else []
211
+
212
+ def logs(self, slug: str, limit: Optional[int] = None,
213
+ before: Union[str, datetime, None] = None) -> List[Dict[str, Any]]:
214
+ """Live runs: decision, notifications, console output, errors."""
215
+ res = self._http.get(f'{_slug_path(slug)}/logs', {'limit': limit, 'before': _iso(before)})
216
+ return res.get('logs', []) if isinstance(res, dict) else []
217
+
218
+ def metrics(self, slug: str, window: Optional[str] = None) -> Dict[str, Any]:
219
+ """``window``: ``1h``, ``24h`` (default), ``7d`` or ``30d``."""
220
+ return self._http.get(f'{_slug_path(slug)}/metrics', {'window': window})
221
+
222
+ def triggers(self) -> Dict[str, Any]:
223
+ """Every trigger with its decisions, capabilities, notification types and a sample event."""
224
+ return self._http.get('/actions/triggers')
225
+
226
+ def templates(self) -> List[Dict[str, Any]]:
227
+ res = self._http.get('/actions/templates')
228
+ return res.get('templates', []) if isinstance(res, dict) else []
229
+
230
+ def render_template(self, id: str, trigger: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
231
+ """A template as plain code (to read, or to start a code Action from)."""
232
+ if not isinstance(id, str) or not _TEMPLATE_RE.match(id):
233
+ raise TouchQueConfigException('Unknown template id')
234
+ return self._http.post(f'/actions/templates/{id}/render', {'trigger': trigger, 'params': params or {}})
@@ -0,0 +1,236 @@
1
+ from typing import Any, Dict, List, Optional
2
+ from urllib.parse import quote
3
+
4
+ import time
5
+
6
+ from ..approval_token import assert_approval_matches, decode_approval_token, verify_approval_token_with
7
+ from ..exceptions import TouchQueApprovalMismatchError, TouchQueConfigException
8
+ from ..http_client import HttpClient
9
+
10
+
11
+ def _public_only(jwk: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
12
+ """Refuses a private key before it leaves this process."""
13
+ if isinstance(jwk, dict) and 'd' in jwk:
14
+ raise TouchQueConfigException(
15
+ "Pass the agent's PUBLIC key: this one has private parts (d). "
16
+ 'The private key must stay where the agent runs.')
17
+ return jwk
18
+
19
+
20
+ def _id(value: str) -> str:
21
+ return quote(value, safe='')
22
+
23
+
24
+ class AgentVault:
25
+ """The token vault for your agents (``client.agents.vault``): after your own OAuth flow with a
26
+ person, deposit their tokens; TouchQue keeps them sealed and refreshes them, and the agents a
27
+ connection names get a short-lived access token (``TouchQueAgent.get_connection_token``),
28
+ never the refresh token."""
29
+
30
+ def __init__(self, http: HttpClient):
31
+ self._http = http
32
+
33
+ def list_connections(self) -> List[Dict[str, Any]]:
34
+ return self._http.get('/agents/vault/connections')['connections']
35
+
36
+ def put_connection(self, name: str, provider: str, client_id: str, client_secret: Optional[str] = None,
37
+ tenant: Optional[str] = None, issuer: Optional[str] = None, agent_ids: Optional[List[str]] = None) -> Dict[str, Any]:
38
+ """Adds or replaces a connection (google, microsoft, github, facebook, linkedin, x, oidc);
39
+ ``client_secret`` is kept when left out of a replacement."""
40
+ body: Dict[str, Any] = {'provider': provider, 'clientId': client_id}
41
+ for key, value in (('clientSecret', client_secret), ('tenant', tenant), ('issuer', issuer), ('agentIds', agent_ids)):
42
+ if value is not None:
43
+ body[key] = value
44
+ return self._http.put(f'/agents/vault/connections/{_id(name)}', body)['connection']
45
+
46
+ def remove_connection(self, name: str) -> Dict[str, Any]:
47
+ """Removes it, and every person's tokens kept for it."""
48
+ return self._http.delete(f'/agents/vault/connections/{_id(name)}')
49
+
50
+ def deposit(self, connection: str, user: str, access_token: str, refresh_token: Optional[str] = None,
51
+ expires_in: Optional[int] = None, scope: Optional[str] = None) -> Dict[str, Any]:
52
+ """Keeps a person's tokens (replacing earlier ones) after your own OAuth flow with them."""
53
+ body: Dict[str, Any] = {'accessToken': access_token}
54
+ for key, value in (('refreshToken', refresh_token), ('expiresIn', expires_in), ('scope', scope)):
55
+ if value is not None:
56
+ body[key] = value
57
+ return self._http.put(f'/agents/vault/accounts/{_id(connection)}/{_id(user)}', body)['account']
58
+
59
+ def list_accounts(self, user: str) -> List[Dict[str, Any]]:
60
+ """A person's accounts in the vault (never a token)."""
61
+ return self._http.get('/agents/vault/accounts', {'externalUsername': user})['accounts']
62
+
63
+ def remove_account(self, connection: str, user: str) -> None:
64
+ """Deletes a person's tokens: agents get nothing more for them there."""
65
+ self._http.delete(f'/agents/vault/accounts/{_id(connection)}/{_id(user)}')
66
+
67
+
68
+ class Agents:
69
+ """AI agents as identities of their own (``client.agents``): register an
70
+ agent with its PUBLIC key, give it an accountable owner and the action
71
+ types it may ask approvals for, suspend, revoke or stop every agent at
72
+ once. The agent itself never gets your API key: it signs in with its own
73
+ key (``TouchQueAgent``).
74
+
75
+ keys = generate_agent_key() # where the agent runs
76
+ agent = client.agents.create('Invoice bot', ['PAY_INVOICE'], public_key=keys['public_jwk'])
77
+ """
78
+
79
+ def __init__(self, http: HttpClient):
80
+ self._http = http
81
+ # The token vault: people's tokens at other services, for your agents.
82
+ self.vault = AgentVault(http)
83
+ self._jwks: Optional[Dict[str, Any]] = None
84
+ self._jwks_at = 0.0
85
+ self._audience: Optional[str] = None
86
+
87
+ def redeem_approval(self, token: str, user: Optional[str] = None, type: Optional[str] = None,
88
+ authorization_details: Any = None) -> Dict[str, Any]:
89
+ """Uses an agent's approval exactly once, right before you execute:
90
+ checks your expectations against the token first (nothing is used on a
91
+ mismatch), then TouchQue checks the token and marks it used, and the
92
+ answer is checked again. Raises TouchQueApprovalMismatchError or a
93
+ TouchQueAPIException (``already_redeemed``, ``token_expired``...)."""
94
+ expect = {'user': user, 'type': type, 'authorization_details': authorization_details}
95
+ assert_approval_matches(decode_approval_token(token)['claims'], **expect)
96
+ approval = self._http.post('/agents/approvals/redeem', {'token': token})['approval']
97
+ assert_approval_matches({'sub': approval.get('externalUsername'), 'type': approval.get('type'),
98
+ 'authorization_details': approval.get('authorizationDetails')}, **expect)
99
+ return approval
100
+
101
+ def approval_audience(self) -> str:
102
+ """The ``aud`` of this workspace's approval tokens (``touchque:workspace:<id>``),
103
+ from GET /agents/approval-audience; asked once, then kept."""
104
+ if not self._audience:
105
+ audience = (self._http.get('/agents/approval-audience') or {}).get('audience')
106
+ if not isinstance(audience, str) or not audience:
107
+ raise TouchQueApprovalMismatchError("Touchque did not name this workspace's approval audience")
108
+ self._audience = audience
109
+ return self._audience
110
+
111
+ def verify_approval_token(self, token: str, audience: Optional[str] = None, **expect: Any) -> Dict[str, Any]:
112
+ """Verifies an approval token offline (TouchQue's published keys, expiry,
113
+ ``aud``, ``user`` / ``type`` / ``authorization_details``). ``aud`` must be
114
+ this workspace's: ``audience`` defaults to ``approval_audience()``, and a
115
+ token without aud or for another workspace is refused. It does not make
116
+ the token single-use: ``redeem_approval`` does."""
117
+ audience = audience or self.approval_audience()
118
+ if self._jwks is None or time.time() - self._jwks_at > 300:
119
+ self._jwks, self._jwks_at = self._http.get('/agents/jwks.json'), time.time()
120
+ try:
121
+ return verify_approval_token_with(token, self._jwks, audience=audience, **expect)
122
+ except TouchQueApprovalMismatchError as exc:
123
+ if 'unknown key' not in str(exc):
124
+ raise
125
+ self._jwks, self._jwks_at = self._http.get('/agents/jwks.json'), time.time()
126
+ return verify_approval_token_with(token, self._jwks, audience=audience, **expect)
127
+
128
+ def create(self, name: str, allowed_types: List[str], public_key: Optional[Dict[str, Any]] = None,
129
+ public_keys: Optional[List[Dict[str, Any]]] = None, description: Optional[str] = None,
130
+ owner_email: Optional[str] = None, platform: Optional[str] = None,
131
+ review_in_days: Optional[int] = None) -> Dict[str, Any]:
132
+ """Registers an agent (needs the ``agents:write`` scope). ``owner_email``:
133
+ the accountable person, a member of the organization (default: the
134
+ API key's creator); the agent stops when they leave."""
135
+ body: Dict[str, Any] = {'name': name, 'allowedTypes': allowed_types}
136
+ if description is not None:
137
+ body['description'] = description
138
+ if owner_email is not None:
139
+ body['ownerEmail'] = owner_email
140
+ if platform is not None:
141
+ body['platform'] = platform
142
+ if review_in_days is not None:
143
+ body['reviewInDays'] = review_in_days
144
+ if public_key is not None:
145
+ body['publicKey'] = _public_only(public_key)
146
+ if public_keys is not None:
147
+ body['publicKeys'] = [_public_only(k) for k in public_keys]
148
+ return self._http.post('/agents', body)['agent']
149
+
150
+ def list(self, status: Optional[str] = None) -> Dict[str, Any]:
151
+ """``{'agents': [...], 'killSwitch': {...}}``."""
152
+ return self._http.get('/agents', {'status': status} if status else None)
153
+
154
+ def get(self, agent_id: str) -> Dict[str, Any]:
155
+ return self._http.get(f'/agents/{_id(agent_id)}')['agent']
156
+
157
+ def update(self, agent_id: str, name: Optional[str] = None, description: Optional[str] = None,
158
+ owner_email: Optional[str] = None, platform: Optional[str] = None,
159
+ allowed_types: Optional[List[str]] = None, review_in_days: Optional[int] = None) -> Dict[str, Any]:
160
+ """Changes, hands over (``owner_email``) or renews (``review_in_days``) an agent."""
161
+ body: Dict[str, Any] = {}
162
+ for key, value in (('name', name), ('description', description), ('ownerEmail', owner_email),
163
+ ('platform', platform), ('allowedTypes', allowed_types), ('reviewInDays', review_in_days)):
164
+ if value is not None:
165
+ body[key] = value
166
+ return self._http.patch(f'/agents/{_id(agent_id)}', body)['agent']
167
+
168
+ def add_key(self, agent_id: str, public_key: Dict[str, Any]) -> Dict[str, Any]:
169
+ """Adds a key (rotation: add the new one, move the agent to it, then remove the old one)."""
170
+ return self._http.post(f'/agents/{_id(agent_id)}/keys', {'publicKey': _public_only(public_key)})['agent']
171
+
172
+ def remove_key(self, agent_id: str, kid: str) -> Dict[str, Any]:
173
+ """Removes a key; tokens the agent got with it stop at once."""
174
+ return self._http.delete(f'/agents/{_id(agent_id)}/keys/{_id(kid)}')['agent']
175
+
176
+ def suspend(self, agent_id: str, reason: Optional[str] = None) -> Dict[str, Any]:
177
+ """Stops an agent at once (its tokens too) until resumed."""
178
+ return self._http.post(f'/agents/{_id(agent_id)}/suspend', {'reason': reason} if reason else {})['agent']
179
+
180
+ def resume(self, agent_id: str) -> Dict[str, Any]:
181
+ return self._http.post(f'/agents/{_id(agent_id)}/resume', {})['agent']
182
+
183
+ def revoke(self, agent_id: str) -> Dict[str, Any]:
184
+ """Revokes an agent for good (the record stays for the audit trail)."""
185
+ return self._http.delete(f'/agents/{_id(agent_id)}')['agent']
186
+
187
+ def set_policy(self, agent_id: str, policy: Dict[str, Any]) -> Dict[str, Any]:
188
+ """Sets the agent's budgets, amounts, recipients and background rule (a
189
+ full replacement; left-out fields take the defaults): requestsPer10Min,
190
+ requestsPerUserPer10Min, amountPerRequest / amountPerDay ({'TRY': '1000.00'}),
191
+ background ('non_critical' | 'never'), newRecipients ('ask' | 'deny'),
192
+ recipientAllowlist (IBANs / account numbers), fourEyes, requirePasskeyAbove
193
+ ({'TRY': '10000.00'}: over it the person approves with a passkey on your
194
+ page, never on the phone or with a mandate)."""
195
+ return self._http.put(f'/agents/{_id(agent_id)}/policy', {'policy': policy})['agent']
196
+
197
+ def list_events(self, after: Optional[str] = None, since: Optional[str] = None, limit: Optional[int] = None) -> Dict[str, Any]:
198
+ """The workspace's agent events for a SIEM, oldest first: ``{'events', 'next',
199
+ 'hasMore'}``. Keep ``next`` and pass it as ``after``; the first call reads from
200
+ ``since`` (ISO 8601; default the last 24 hours)."""
201
+ params: Dict[str, Any] = {}
202
+ for key, value in (('after', after), ('since', since), ('limit', limit)):
203
+ if value is not None:
204
+ params[key] = value
205
+ return self._http.get('/agents/events', params or None)
206
+
207
+ def get_workspace_policy(self) -> Dict[str, Any]:
208
+ """The workspace's policy: every agent's floor (an agent's own policy can only make it stricter)."""
209
+ return self._http.get('/agents/workspace-policy')['policy']
210
+
211
+ def set_workspace_policy(self, policy: Dict[str, Any]) -> Dict[str, Any]:
212
+ """Sets the workspace's policy (a full replacement; a field left out sets no floor)."""
213
+ return self._http.put('/agents/workspace-policy', {'policy': policy})['policy']
214
+
215
+ def list_blocks(self, agent_id: str) -> List[Dict[str, Any]]:
216
+ """The people who stopped this agent asking them (reason not_me, coached or rejections)."""
217
+ return self._http.get(f'/agents/{_id(agent_id)}/blocks')['blocks']
218
+
219
+ def lift_block(self, agent_id: str, user: str) -> None:
220
+ """Lets the agent ask this person again (check with them first)."""
221
+ self._http.delete(f'/agents/{_id(agent_id)}/blocks/{_id(user)}')
222
+
223
+ def list_mandates(self, agent_id: str, user: Optional[str] = None) -> List[Dict[str, Any]]:
224
+ """An agent's mandates, optionally one person's."""
225
+ return self._http.get(f'/agents/{_id(agent_id)}/mandates', {'externalUsername': user} if user else None)['mandates']
226
+
227
+ def revoke_mandate(self, agent_id: str, mandate_id: str) -> Dict[str, Any]:
228
+ """Ends an agent's mandate at once."""
229
+ return self._http.post(f'/agents/{_id(agent_id)}/mandates/{_id(mandate_id)}/revoke', {})
230
+
231
+ def set_kill_switch(self, engaged: bool, reason: Optional[str] = None) -> Dict[str, Any]:
232
+ """Stops (True) or restarts (False) every agent of the workspace at once."""
233
+ body: Dict[str, Any] = {'engaged': engaged}
234
+ if reason:
235
+ body['reason'] = reason
236
+ return self._http.put('/agents/kill-switch', body)['killSwitch']
@@ -0,0 +1,115 @@
1
+ """Help desk: IT working on a person's account in the open.
2
+
3
+ 1. Before your help desk acts on someone's account: ``open`` with the person
4
+ and the signed-in agent. The person's phone gets a calm notice ("<agent>
5
+ from IT is working on your account now"); while the session is open, their
6
+ "someone says they're from IT" button shows this agent, and with none open
7
+ it tells them to hang up (webhook ``ASSIST_CLAIM_UNVERIFIED``).
8
+ 2. Before a sensitive action (a factor or password reset, an activation or
9
+ recovery link, an unlock, a new phone, removing a passkey): ``verify``. Show
10
+ ``challengeCode`` to the agent only; they read it to the person, who types
11
+ it on their phone and approves with Face ID. Default deny.
12
+ 3. Right before the action: ``spend`` (a passed check, for that action, at most
13
+ ten minutes old, once). ``unfreeze`` lifts a freeze the same way.
14
+
15
+ No phone (``code`` ``no_phone``, ``data['route']`` ``never_locked_out``): the
16
+ person moves to a new phone with Never Locked Out; the help desk cannot.
17
+ """
18
+ from typing import Any, Dict, List, Optional
19
+ from urllib.parse import quote
20
+
21
+ from ..exceptions import TouchQueConfigException
22
+ from ..http_client import HttpClient
23
+
24
+
25
+ def _path(id: str, rest: str = '') -> str:
26
+ return f'/assist/{quote(id, safe="")}{rest}'
27
+
28
+
29
+ def is_assist_check_passed(session: Dict[str, Any], request_id: str) -> bool:
30
+ """The check of ``request_id`` passed on the person's phone and was not used yet."""
31
+ for check in session.get('checks') or []:
32
+ if check.get('requestId') == request_id:
33
+ return check.get('outcome') == 'verified' and not check.get('spentAt')
34
+ return False
35
+
36
+
37
+ class Assist:
38
+ """IT working on a person's account, and checks on their phone before sensitive actions."""
39
+
40
+ def __init__(self, http: HttpClient):
41
+ self._http = http
42
+
43
+ def open(self, external_username: str, agent: Dict[str, str], reason: Optional[str] = None,
44
+ ttl_minutes: Optional[int] = None, notify: Optional[bool] = None) -> Dict[str, Any]:
45
+ """Starts helping a person. ``agent``: ``{'name', 'title', 'ref'}`` (``ref`` is your id for them).
46
+ A session of the same agent for the same person is replaced."""
47
+ if not external_username or not (agent or {}).get('name'):
48
+ raise TouchQueConfigException('assist.open() needs external_username and agent["name"]')
49
+ payload: Dict[str, Any] = {'externalUsername': external_username, 'agent': {k: v for k, v in agent.items() if v}}
50
+ if reason:
51
+ payload['reason'] = reason
52
+ if ttl_minutes is not None:
53
+ payload['ttlMinutes'] = ttl_minutes
54
+ if notify is not None:
55
+ payload['notify'] = notify
56
+ return self._http.post('/assist', payload)
57
+
58
+ def get(self, id: str) -> Dict[str, Any]:
59
+ """The session and its checks. Poll it while a check is ``pending``."""
60
+ if not id:
61
+ raise TouchQueConfigException('assist.get() needs the session id')
62
+ return self._http.get(_path(id))
63
+
64
+ def list(self, external_username: Optional[str] = None, open_only: bool = False,
65
+ open: Optional[bool] = None) -> List[Dict[str, Any]]:
66
+ """A person's sessions, or every open one, newest first.
67
+ ``open=True`` is the same as ``open_only=True`` (the name ``recovery.list`` and the other SDKs use)."""
68
+ open_only = open_only or bool(open)
69
+ if not external_username and not open_only:
70
+ raise TouchQueConfigException('assist.list() needs external_username or open_only=True')
71
+ params: Dict[str, Any] = {}
72
+ if external_username:
73
+ params['externalUsername'] = external_username
74
+ if open_only:
75
+ params['open'] = 'true'
76
+ res = self._http.get('/assist', params)
77
+ return list((res or {}).get('sessions') or [])
78
+
79
+ def verify(self, id: str, action: str, action_text: Optional[str] = None,
80
+ details: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
81
+ """Asks the person's phone to confirm ``action`` (``reset_2fa``, ``reset_password``, ``activation_link``,
82
+ ``recovery_link``, ``unlock``, ``new_phone``, ``remove_passkey`` or your own). Returns ``requestId`` and
83
+ ``challengeCode`` (for the agent only). Raises TouchQueAPIException ``no_phone``, ``assist_not_open`` or
84
+ ``rate_limited``."""
85
+ if not id or not action:
86
+ raise TouchQueConfigException('assist.verify() needs the session id and an action')
87
+ payload: Dict[str, Any] = {'action': action}
88
+ if action_text:
89
+ payload['actionText'] = action_text
90
+ if details:
91
+ payload['details'] = details
92
+ return self._http.post(_path(id, '/verify'), payload)
93
+
94
+ def spend(self, id: str, request_id: str, action: str) -> Dict[str, Any]:
95
+ """Uses a passed check for its action, right before you take it; ``check_required`` (428) otherwise."""
96
+ if not id or not request_id or not action:
97
+ raise TouchQueConfigException('assist.spend() needs the session id, request_id and action')
98
+ return self._http.post(_path(id, '/spend'), {'requestId': request_id, 'action': action})
99
+
100
+ def unfreeze(self, id: str, request_id: str) -> Dict[str, Any]:
101
+ """Lifts the person's approval freeze with a passed check for ``unlock``: ``{'unfrozen', 'session'}``."""
102
+ if not id or not request_id:
103
+ raise TouchQueConfigException('assist.unfreeze() needs the session id and request_id')
104
+ return self._http.post(_path(id, '/unfreeze'), {'requestId': request_id})
105
+
106
+ def end(self, id: str, by: Optional[str] = None, reason: Optional[str] = None) -> Dict[str, Any]:
107
+ """Stops helping; checks still waiting on the phone expire."""
108
+ if not id:
109
+ raise TouchQueConfigException('assist.end() needs the session id')
110
+ payload: Dict[str, Any] = {}
111
+ if by:
112
+ payload['by'] = by
113
+ if reason:
114
+ payload['reason'] = reason
115
+ return self._http.post(_path(id, '/end'), payload)