195 lines
9.8 KiB
Python
195 lines
9.8 KiB
Python
"""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
|