| """core/keychain.py β per-tenant CREDENTIAL STORE (wave 18, contract C7 / ruling R3). |
| |
| An admin stores a data source's secrets (Royal's Odoo API key today; any tenant's sources |
| tomorrow) encrypted at rest in the TENANT's store bucket. The runtime posture is R3's |
| "live with env fallback": the connector layer asks `odoo_creds(rt)` FIRST and falls back to |
| the process environment (tenant #0's `.env` / Space secrets), so Royal keeps working with an |
| empty keychain and a new tenant needs no code to bring keys. The FULL cutover (env retired) |
| is staged for a later wave β R3, verbatim. |
| |
| CRYPTO: Fernet (AES128-CBC + HMAC, the `cryptography` package) under ONE platform key, |
| `AIOS_KEYCHAIN_KEY` (a `Fernet.generate_key()` value; a Space secret, never in the store). |
| No key β the keychain is LOCKED: metadata still lists (labels are not secrets), but nothing |
| decrypts and writes are refused β fail closed, never a plaintext fallback. |
| |
| SHAPE (bucket `keychain`, per-tenant via the runtime handle): |
| |
| {entries: {id: {id, label, type, enc, preview, created, createdBy}}} |
| |
| `enc` = Fernet token over the JSON fields dict. `preview` = a masked hint ("Β·Β·Β·Β·1234") |
| computed ONCE at write from the entry's most secret-looking field β the ONLY part of a |
| secret that ever leaves this module through a route. `read_fields` exists for the CONNECTOR |
| LAYER; no route returns its output. |
| """ |
| import datetime as _dt |
| import json |
| import os |
| import secrets as _secrets |
|
|
| KEY = 'keychain' |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ENTRY_TYPES = {'odoo', 'generic', 'stripe', 'shopify', 'meta_ads'} |
| |
| |
| |
| |
| |
| |
| |
| _PREVIEW_FIELDS = ('api_key', 'access_token', 'value', 'password', 'token') |
| MAX_ENTRIES = 40 |
| MAX_FIELD_LEN = 500 |
|
|
|
|
| class KeychainLocked(Exception): |
| """Raised when AIOS_KEYCHAIN_KEY is absent/wrong β callers answer 503, never {}.""" |
|
|
|
|
| def _fernet(): |
| key = (os.environ.get('AIOS_KEYCHAIN_KEY') or '').strip() |
| if not key: |
| raise KeychainLocked('AIOS_KEYCHAIN_KEY is not configured') |
| try: |
| from cryptography.fernet import Fernet |
| return Fernet(key.encode('ascii')) |
| except Exception as e: |
| raise KeychainLocked(f'keychain key unusable: {type(e).__name__}') |
|
|
|
|
| def unlocked(): |
| try: |
| _fernet() |
| return True |
| except KeychainLocked: |
| return False |
|
|
|
|
| def _bucket(rt): |
| return rt.get(KEY) or {} |
|
|
|
|
| def list_entries(rt): |
| """Metadata ONLY β never a decrypted field, never `enc` itself.""" |
| out = [] |
| for eid, e in sorted((_bucket(rt).get('entries') or {}).items()): |
| if not isinstance(e, dict): |
| continue |
| out.append({'id': eid, 'label': e.get('label') or eid, |
| 'type': e.get('type') or 'generic', |
| 'preview': e.get('preview') or '', |
| 'created': e.get('created') or '', |
| 'createdBy': e.get('createdBy') or ''}) |
| return out |
|
|
|
|
| def add_entry(rt, label, etype, fields, username): |
| """Encrypt + store. Returns the metadata row, or raises ValueError on shape problems and |
| KeychainLocked when there is no key (a secret must never be stored in the clear).""" |
| label = ' '.join(str(label or '').split())[:80] |
| etype = str(etype or '').strip().lower() |
| if not label: |
| raise ValueError('give the key a label') |
| if etype not in ENTRY_TYPES: |
| raise ValueError(f"type must be one of {', '.join(sorted(ENTRY_TYPES))}") |
| if not isinstance(fields, dict) or not fields: |
| raise ValueError('fields must be a non-empty object') |
| clean = {} |
| for k, v in list(fields.items())[:12]: |
| k = str(k).strip()[:40] |
| if k and isinstance(v, (str, int, float, bool)): |
| clean[k] = str(v)[:MAX_FIELD_LEN] |
| if not clean: |
| raise ValueError('no usable fields') |
| token = _fernet().encrypt(json.dumps(clean, ensure_ascii=False).encode('utf-8')) |
| secretish = next((clean[k] for k in _PREVIEW_FIELDS if clean.get(k)), '') |
| preview = ('Β·Β·Β·Β·' + secretish[-4:]) if len(secretish) >= 4 else ('Β·Β·Β·Β·' if secretish else '') |
| eid = f'k_{_secrets.token_hex(6)}' |
| row = {'id': eid, 'label': label, 'type': etype, 'enc': token.decode('ascii'), |
| 'preview': preview, |
| 'created': _dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), |
| 'createdBy': str(username or '')} |
|
|
| def _up(cur): |
| entries = cur.setdefault('entries', {}) |
| if len(entries) >= MAX_ENTRIES: |
| raise ValueError(f'this keychain is at its {MAX_ENTRIES}-entry cap') |
| entries[eid] = row |
| return cur |
|
|
| rt.update(KEY, _up, flush='sync') |
| return {'id': eid, 'label': label, 'type': etype, 'preview': preview, |
| 'created': row['created'], 'createdBy': row['createdBy']} |
|
|
|
|
| def delete_entry(rt, entry_id): |
| def _drop(cur): |
| (cur.get('entries') or {}).pop(str(entry_id), None) |
| return cur |
| rt.update(KEY, _drop, flush='sync') |
| return True |
|
|
|
|
| def read_fields(rt, entry_id): |
| """Decrypt ONE entry's fields β for the CONNECTOR LAYER only; no route returns this. |
| None when the entry does not exist; KeychainLocked when the key is absent/wrong (a wrong |
| key must be loud β silently empty creds would read as 'not configured').""" |
| e = (_bucket(rt).get('entries') or {}).get(str(entry_id)) |
| if not isinstance(e, dict) or not e.get('enc'): |
| return None |
| try: |
| raw = _fernet().decrypt(str(e['enc']).encode('ascii')) |
| except KeychainLocked: |
| raise |
| except Exception: |
| raise KeychainLocked('this entry does not decrypt under the configured key') |
| try: |
| data = json.loads(raw.decode('utf-8')) |
| return data if isinstance(data, dict) else None |
| except Exception: |
| return None |
|
|
|
|
| def odoo_creds(rt): |
| """R3's resolver seam: the FIRST odoo-type entry's fields, else None (the caller falls |
| back to the environment). Deterministic order = insertion-id sort, so 'first' is stable.""" |
| return _first_creds(rt, 'odoo') |
|
|
|
|
| def meta_creds(rt): |
| """W31 (R2): the FIRST `meta_ads` entry's fields, else None β the Meta connector's resolver. |
| |
| β IT IS DELIBERATELY THE SAME SHAPE AS `odoo_creds` AND SHARES ITS IMPLEMENTATION, because |
| "which stored credential serves this connector?" is ONE question and this module was one |
| copy-paste away from having two answers to it ([[one-question-two-normalizers]]). The |
| difference between the two connectors belongs in the CONNECTOR, not in the lookup. |
| |
| β AND IT FAILS CLOSED WITH NO ENVIRONMENT FALLBACK, which is where it deliberately DIFFERS |
| from `odoo_creds`'s caller. `harness/runtime.py::odoo_source` falls back to the process |
| environment for tenant #0 β a documented exception for the tenant whose `.env` this is. There |
| is no equivalent for Meta: `META_ADS_ACCESS_TOKEN` is the OWNER's measurement token (R8), and |
| handing it to a tenant whose admin has not stored one is precisely the leak the R3 rule |
| exists to prevent. A tenant with no entry gets None, and the connector says so. |
| """ |
| return _first_creds(rt, 'meta_ads') |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| SCOPE_KEY = 'keychain_scopes' |
|
|
| |
| |
| |
| |
| |
| |
| TENANT_WIDE_TYPES = ('odoo', 'meta_ads') |
|
|
|
|
| def _personal_ids(rt): |
| """Entry ids marked `personal`. Absent bucket β empty β today's behaviour, unchanged.""" |
| try: |
| rows = rt.get(SCOPE_KEY) or {} |
| except Exception: |
| return frozenset() |
| if not isinstance(rows, dict): |
| return frozenset() |
| return frozenset(str(k) for k, v in rows.items() |
| if isinstance(v, dict) and str(v.get('scope') or '') == 'personal') |
|
|
|
|
| def _first_creds(rt, etype): |
| """The first entry of `etype`, decrypted β or None. Deterministic order = insertion-id sort, |
| so "first" is stable across reads rather than dict-order luck. |
| |
| ββ WAVE 32 (R4/C1) β A PERSONAL ENTRY OF A TENANT-WIDE TYPE IS SKIPPED, NOT SERVED. |
| `routes_keychain` REFUSES to create one, and that closes the door from the only side B owns; |
| this is the other side, and it is the one that matters for anything already stored β an entry |
| written before this wave, by an older client, or by any path that does not go through that |
| route. B booked it for A explicitly ("the resolver-side guard is booked for A"). |
| |
| β SKIP, NOT RAISE. The caller's contract is "the tenant's credential, or None", and None already |
| means "not configured" everywhere it is consumed: `odoo_source` falls back to the environment |
| for tenant #0, and Meta fails closed with no fallback. Raising would turn a mis-scoped entry |
| into an outage for a workspace that has a perfectly good business entry two rows down. |
| β AND THE SKIP IS SCOPED TO `TENANT_WIDE_TYPES`. A personal entry of any OTHER type is a normal |
| personal credential and this resolver is not how it is reached. |
| """ |
| skip = _personal_ids(rt) if etype in TENANT_WIDE_TYPES else frozenset() |
| for row in list_entries(rt): |
| if row['type'] == etype and str(row['id']) not in skip: |
| return read_fields(rt, row['id']) |
| return None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| import re as _re_slots |
|
|
| USER_SECRET_SLOT = _re_slots.compile(r'^oauth_[a-z0-9_]{1,24}$') |
|
|
|
|
| def put_user_secret(rt, username, provider, fields): |
| """Encrypt + store ONE user's credential slot for `provider` (upsert β a reconnect |
| replaces). Raises ValueError on shape problems, KeychainLocked when there is no key.""" |
| username = str(username or '').strip() |
| provider = str(provider or '').strip().lower() |
| if not username: |
| raise ValueError('no username for the secret slot') |
| if not USER_SECRET_SLOT.fullmatch(provider): |
| raise ValueError('a secret slot is named oauth_<provider>') |
| if not isinstance(fields, dict) or not fields: |
| raise ValueError('fields must be a non-empty object') |
| clean = {} |
| for k, v in list(fields.items())[:16]: |
| k = str(k).strip()[:40] |
| if k and isinstance(v, (str, int, float, bool)): |
| clean[k] = str(v)[:2000] |
| if not clean: |
| raise ValueError('no usable fields') |
| token = _fernet().encrypt(json.dumps(clean, ensure_ascii=False).encode('utf-8')) |
| row = {'enc': token.decode('ascii'), |
| 'updated': _dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S')} |
|
|
| def _up(cur): |
| cur.setdefault('user_secrets', {}).setdefault(username, {})[provider] = row |
| return cur |
|
|
| rt.update(KEY, _up, flush='sync') |
| return True |
|
|
|
|
| def read_user_secret(rt, username, provider): |
| """Decrypt ONE user's slot β None when absent; KeychainLocked stays LOUD (a wrong key must |
| never read as 'not connected').""" |
| slot = ((_bucket(rt).get('user_secrets') or {}).get(str(username)) or {}).get( |
| str(provider or '').lower()) |
| if not isinstance(slot, dict) or not slot.get('enc'): |
| return None |
| try: |
| raw = _fernet().decrypt(str(slot['enc']).encode('ascii')) |
| except KeychainLocked: |
| raise |
| except Exception: |
| raise KeychainLocked('this slot does not decrypt under the configured key') |
| try: |
| data = json.loads(raw.decode('utf-8')) |
| return data if isinstance(data, dict) else None |
| except Exception: |
| return None |
|
|
|
|
| def drop_user_secret(rt, username, provider): |
| def _drop(cur): |
| slots = (cur.get('user_secrets') or {}).get(str(username)) |
| if isinstance(slots, dict): |
| slots.pop(str(provider or '').lower(), None) |
| return cur |
| rt.update(KEY, _drop, flush='sync') |
| return True |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _PROBES = {} |
|
|
|
|
| def register_prober(etype, fn): |
| """Let the connector layer answer `test_entry` for ONE entry type. |
| |
| β Additive and idempotent by design β a re-import must not double-register or raise, because |
| module import order is not something a caller controls. |
| """ |
| etype = str(etype or '').strip().lower() |
| if etype and callable(fn): |
| _PROBES[etype] = fn |
| return etype in _PROBES |
|
|
|
|
| def test_entry(rt, entry_id, timeout=8): |
| """A cheap, READ-ONLY liveness probe. odoo: xmlrpc `common.version()` (no auth, no data); |
| generic: shape-only. Always answers {ok, message} β an exception here is a result, not a |
| crash.""" |
| try: |
| e = (_bucket(rt).get('entries') or {}).get(str(entry_id)) |
| if not isinstance(e, dict): |
| return {'ok': False, 'message': 'no such entry'} |
| fields = read_fields(rt, entry_id) or {} |
| |
| |
| |
| |
| prober = _PROBES.get(str(e.get('type') or '')) |
| if prober is not None: |
| try: |
| got = prober(dict(fields), timeout) |
| return got if isinstance(got, dict) else {'ok': False, |
| 'message': 'the prober said nothing'} |
| except Exception as ex: |
| return {'ok': False, 'message': f'{type(ex).__name__}: {str(ex)[:160]}'} |
| if e.get('type') != 'odoo': |
| return {'ok': bool(fields), 'message': f'{len(fields)} field(s) stored'} |
| url = str(fields.get('url') or '').rstrip('/') |
| if not url.startswith(('http://', 'https://')): |
| return {'ok': False, 'message': 'url must start with http(s)://'} |
| import socket |
| import xmlrpc.client |
| old = socket.getdefaulttimeout() |
| socket.setdefaulttimeout(timeout) |
| try: |
| common = xmlrpc.client.ServerProxy(f'{url}/xmlrpc/2/common', allow_none=True) |
| ver = common.version() |
| finally: |
| socket.setdefaulttimeout(old) |
| sv = (ver or {}).get('server_version') if isinstance(ver, dict) else None |
| return {'ok': True, 'message': f'Odoo answered (server {sv or "unknown"})'} |
| except KeychainLocked as e: |
| return {'ok': False, 'message': str(e)} |
| except Exception as e: |
| return {'ok': False, 'message': f'{type(e).__name__}: {str(e)[:120]}'} |
|
|