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.
- touchque/__init__.py +148 -0
- touchque/agent.py +314 -0
- touchque/agent_card.py +190 -0
- touchque/agent_frameworks.py +215 -0
- touchque/approval_proof.py +104 -0
- touchque/approval_token.py +134 -0
- touchque/client.py +105 -0
- touchque/code_reply.py +75 -0
- touchque/config.py +56 -0
- touchque/contrib/__init__.py +4 -0
- touchque/contrib/django.py +175 -0
- touchque/contrib/fastapi.py +208 -0
- touchque/contrib/flask.py +154 -0
- touchque/cross_app_access.py +222 -0
- touchque/exceptions.py +170 -0
- touchque/guard.py +290 -0
- touchque/hosted_login.py +474 -0
- touchque/http_client.py +156 -0
- touchque/mcp_auth.py +256 -0
- touchque/my_account.py +173 -0
- touchque/org_agent.py +262 -0
- touchque/rate_limiter.py +56 -0
- touchque/relay.py +643 -0
- touchque/resources/__init__.py +1 -0
- touchque/resources/action_types.py +52 -0
- touchque/resources/actions.py +234 -0
- touchque/resources/agents.py +236 -0
- touchque/resources/assist.py +115 -0
- touchque/resources/auth.py +65 -0
- touchque/resources/device_risk.py +174 -0
- touchque/resources/identity.py +268 -0
- touchque/resources/login.py +296 -0
- touchque/resources/mobile.py +130 -0
- touchque/resources/offline.py +138 -0
- touchque/resources/qr_pairings.py +93 -0
- touchque/resources/recovery.py +118 -0
- touchque/resources/risk.py +141 -0
- touchque/resources/safe_guard.py +99 -0
- touchque/resources/webauthn.py +115 -0
- touchque/resources/webhook.py +258 -0
- touchque/safe_guard.py +308 -0
- touchque/security_events.py +205 -0
- touchque/steps.py +239 -0
- touchque_authenticator-4.2.0.dist-info/METADATA +630 -0
- touchque_authenticator-4.2.0.dist-info/RECORD +48 -0
- touchque_authenticator-4.2.0.dist-info/WHEEL +5 -0
- touchque_authenticator-4.2.0.dist-info/licenses/LICENSE +21 -0
- 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)
|