Finish Persims consolidation
This commit is contained in:
1 parent
48c07c3c62
commit
84fa9e70e6
1 file changed
+195
@@ -0,0 +1,195 @@
|
||||
"""Named-vault Juice. Only the pinned extension can submit derived PATs.
|
||||
|
||||
Actions variables hold an allowlisted, value-free operation ledger. There is no
|
||||
secret GET, raw source credential input, MCP registration, or bearer fallback.
|
||||
"""
|
||||
import asyncio
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from . import gitea, human_vault
|
||||
|
||||
ORIGINS = {'https://teahub.xyz'}
|
||||
SCOPES = {'read:repository', 'read:issue', 'write:issue'}
|
||||
ID = re.compile(r'[0-9a-f]{32}')
|
||||
USERNAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,63}')
|
||||
GATE = asyncio.Lock()
|
||||
FIELDS = {'id', 'source_id', 'source_origin', 'source_username', 'provider', 'scopes',
|
||||
'scope_boundary', 'created_at', 'status', 'provider_status', 'target_status',
|
||||
'repo_id', 'replaces'}
|
||||
|
||||
|
||||
def scopes(value):
|
||||
if (not isinstance(value, list) or not 1 <= len(value) <= 3 or
|
||||
any(not isinstance(s, str) or s not in SCOPES for s in value) or
|
||||
len(set(value)) != len(value) or {'read:issue', 'write:issue'} <= set(value)):
|
||||
raise ValueError()
|
||||
return sorted(value)
|
||||
|
||||
|
||||
def safe(value, vault):
|
||||
if (not isinstance(value, dict) or set(value) != FIELDS or
|
||||
value['provider'] != 'gitea' or value['source_origin'] not in ORIGINS or
|
||||
not ID.fullmatch(value['id']) or not ID.fullmatch(value['source_id']) or
|
||||
not USERNAME.fullmatch(value['source_username']) or
|
||||
value['repo_id'] != vault['id'] or
|
||||
value['scope_boundary'] != 'provider-api-class-wide' or
|
||||
value['status'] not in ('pending', 'active', 'revoked', 'partial-failure') or
|
||||
value['provider_status'] not in ('unknown', 'active', 'revoked') or
|
||||
value['target_status'] not in ('absent', 'unknown', 'stored') or
|
||||
(value['replaces'] is not None and not ID.fullmatch(value['replaces']))):
|
||||
raise ValueError()
|
||||
scopes(value['scopes'])
|
||||
datetime.fromisoformat(value['created_at'])
|
||||
return {key: value[key] for key in FIELDS}
|
||||
|
||||
|
||||
def variable(user, vault, item_id):
|
||||
result = gitea.actions(user, vault, 'variables', 'PERSIM_JUICE_' + item_id)
|
||||
return safe(json.loads(result['data']), vault)
|
||||
|
||||
|
||||
def save(user, vault, record, method='PUT'):
|
||||
gitea.actions(user, vault, 'variables', 'PERSIM_JUICE_' + record['id'], method,
|
||||
{'value': json.dumps(safe(record, vault), separators=(',', ':'))})
|
||||
|
||||
|
||||
def operate(user, vault, operation, data):
|
||||
if operation == 'list':
|
||||
rows = gitea.actions(user, vault, 'variables')
|
||||
items = []
|
||||
for row in rows:
|
||||
if re.fullmatch(r'PERSIM_JUICE_[0-9A-F]{32}', row.get('name', '')):
|
||||
item = safe(json.loads(row['data']), vault)
|
||||
if row['name'] != 'PERSIM_JUICE_' + item['id'].upper():
|
||||
raise ValueError()
|
||||
items.append(item)
|
||||
return {'items': items}
|
||||
item_id = data.get('id')
|
||||
if not isinstance(item_id, str) or not ID.fullmatch(item_id):
|
||||
raise ValueError()
|
||||
if operation == 'prepare':
|
||||
if set(data) != {'target', 'id', 'source_id', 'source_username', 'scopes', 'replaces'}:
|
||||
raise ValueError()
|
||||
source_id = data['source_id']
|
||||
if not isinstance(source_id, str) or not ID.fullmatch(source_id):
|
||||
raise ValueError()
|
||||
# Metadata only: never fetch or decrypt the source ciphertext here.
|
||||
found, code = human_vault.Store(os.environ['PERSIMMONS_HUMAN_VAULT_DB']).operate(user['owner'], 'GET')
|
||||
source = next((i for i in found['items'] if i['id'] == source_id), None)
|
||||
if code != 200 or not source or source['origin'] not in ORIGINS:
|
||||
raise ValueError()
|
||||
if not isinstance(data['source_username'], str) or not USERNAME.fullmatch(data['source_username']):
|
||||
raise ValueError()
|
||||
if source.get('username') and source['username'] != data['source_username']:
|
||||
raise ValueError()
|
||||
replaces = data['replaces']
|
||||
if replaces is not None:
|
||||
if not isinstance(replaces, str) or not ID.fullmatch(replaces):
|
||||
raise ValueError()
|
||||
old = variable(user, vault, replaces)
|
||||
if old['status'] != 'active' or old['source_id'] != source_id or old['scopes'] != scopes(data['scopes']):
|
||||
raise ValueError()
|
||||
record = dict(id=item_id, source_id=source_id, source_origin=source['origin'],
|
||||
source_username=data['source_username'], provider='gitea', scopes=scopes(data['scopes']),
|
||||
scope_boundary='provider-api-class-wide', created_at=datetime.now(timezone.utc).isoformat(),
|
||||
status='pending', provider_status='unknown', target_status='absent', repo_id=vault['id'], replaces=replaces)
|
||||
# POST conflicts instead of adopting/replacing a prior operation.
|
||||
save(user, vault, record, 'POST')
|
||||
return {'item': record}
|
||||
record = variable(user, vault, item_id)
|
||||
if operation == 'write':
|
||||
if set(data) != {'target', 'id', 'pat'} or record['status'] != 'pending':
|
||||
raise ValueError()
|
||||
if not isinstance(data['pat'], str) or not re.fullmatch(r'[0-9a-f]{40}', data['pat']):
|
||||
raise ValueError()
|
||||
record.update(target_status='unknown', provider_status='active', status='partial-failure')
|
||||
save(user, vault, record)
|
||||
try:
|
||||
gitea.actions(user, vault, 'secrets', 'PERSIM_JUICE_' + item_id, 'PUT', {'data': data['pat']})
|
||||
finally:
|
||||
data['pat'] = ''
|
||||
record.update(target_status='stored', status='active')
|
||||
save(user, vault, record)
|
||||
elif operation in ('remove', 'cleanup'):
|
||||
expected = {'target', 'id'} | ({'provider_status'} if operation == 'cleanup' else set())
|
||||
if set(data) != expected or (operation == 'cleanup' and data['provider_status'] not in ('revoked', 'unknown')):
|
||||
raise ValueError()
|
||||
try:
|
||||
gitea.actions(user, vault, 'secrets', 'PERSIM_JUICE_' + item_id, 'DELETE')
|
||||
except gitea.Failure as error:
|
||||
if error.code != 404:
|
||||
raise
|
||||
record['target_status'] = 'absent'
|
||||
if operation == 'cleanup':
|
||||
record['provider_status'] = data['provider_status']
|
||||
record['status'] = 'revoked' if record['provider_status'] == 'revoked' else 'partial-failure'
|
||||
save(user, vault, record)
|
||||
else:
|
||||
raise ValueError()
|
||||
return {'item': record}
|
||||
|
||||
|
||||
def endpoint(resolve, database):
|
||||
async def handler(request):
|
||||
operation = request.path_params['operation']
|
||||
origin = request.headers.getlist('origin')
|
||||
extension = origin == [human_vault.EXTENSION_ORIGIN]
|
||||
page = origin == ['https://persimmons.app']
|
||||
headers = {'Cache-Control': 'no-store', 'Vary': 'Origin'}
|
||||
def reply(data, code=200):
|
||||
return JSONResponse(data, code, headers=headers)
|
||||
if (request.url.scheme != 'https' or request.url.query or request.headers.get('authorization') or
|
||||
not (extension or (page and operation in ('list', 'remove')))):
|
||||
return reply({'error': 'forbidden'}, 403)
|
||||
if extension:
|
||||
headers.update({'Access-Control-Allow-Origin': human_vault.EXTENSION_ORIGIN,
|
||||
'Access-Control-Allow-Credentials': 'true'})
|
||||
if request.method == 'OPTIONS':
|
||||
requested = {h.strip().lower() for h in request.headers.get('access-control-request-headers', '').split(',') if h.strip()}
|
||||
if not extension or request.headers.get('access-control-request-method') != 'POST' or not requested <= {'content-type', 'x-vault-owner'}:
|
||||
return reply({'error': 'forbidden'}, 403)
|
||||
return Response(status_code=204, headers={**headers, 'Access-Control-Allow-Methods': 'POST',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-Vault-Owner'})
|
||||
user = await run_in_threadpool(resolve, request)
|
||||
if not user:
|
||||
return reply({'error': 'unauthorized'}, 401)
|
||||
if (request.headers.getlist('x-vault-owner') != [user['owner']] or
|
||||
(page and request.headers.get('x-requested-with') != 'Persimmons')):
|
||||
return reply({'error': 'owner mismatch'}, 403)
|
||||
if request.headers.get('content-type') != 'application/json':
|
||||
return reply({'error': 'JSON required'}, 415)
|
||||
try:
|
||||
raw = bytearray()
|
||||
async for chunk in request.stream():
|
||||
raw.extend(chunk)
|
||||
if len(raw) > 4096:
|
||||
raise ValueError()
|
||||
data = json.loads(raw)
|
||||
raw.clear()
|
||||
target = data['target']
|
||||
if not isinstance(target, dict) or set(target) != {'id', 'name'} or type(target['id']) is not int:
|
||||
raise ValueError()
|
||||
if gitea.repo_name(target['name']) != target['name']:
|
||||
raise ValueError()
|
||||
with closing(database()) as db:
|
||||
row = db.execute('SELECT repo_id FROM named_vaults WHERE owner=? AND name=?', (user['owner'], target['name'])).fetchone()
|
||||
if not row or row[0] != target['id']:
|
||||
return reply({'error': 'target mismatch'}, 403)
|
||||
if operation == 'list' and set(data) != {'target'}:
|
||||
raise ValueError()
|
||||
async with GATE:
|
||||
payload = await run_in_threadpool(operate, user, target, operation, data)
|
||||
return reply({'owner': user['owner'], **payload})
|
||||
except (ValueError, KeyError, TypeError, AttributeError):
|
||||
return reply({'error': 'Invalid Juice request or metadata.'}, 400)
|
||||
except (gitea.Failure, sqlite3.Error, OSError):
|
||||
# A failed mutation is deliberately ambiguous; extension compensates.
|
||||
return reply({'error': 'Juice unavailable. Check operation status before retrying.'}, 503)
|
||||
return handler
|
||||
Reference in new issue
Block a user