Add revisioned decisions and scoped consultation lifecycle
This commit is contained in:
+40
-4
@@ -198,10 +198,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return True
|
||||
|
||||
def auth(self):
|
||||
self.decision_scope = None
|
||||
supplied = self.headers.get('Authorization', '')
|
||||
if not hmac.compare_digest(supplied.encode('utf-8'), ('Bearer ' + self.server.token).encode('utf-8')):
|
||||
if hmac.compare_digest(supplied.encode('utf-8'), ('Bearer ' + self.server.token).encode('utf-8')):
|
||||
return True
|
||||
scope = self.server.store.decision_credential_scope(supplied[7:]) if supplied.startswith('Bearer ') else None
|
||||
if scope is None:
|
||||
self.output(401, {'error':'authentication required'})
|
||||
return False
|
||||
path = urlsplit(self.path).path
|
||||
base = '/api/decisions/' + scope
|
||||
allowed = ((self.command=='GET' and path in (base,base+'/history',base+'/consultation'))
|
||||
or (self.command=='PATCH' and path==base)
|
||||
or (self.command=='POST' and path==base+'/facts'))
|
||||
if not allowed or (self.command!='GET' and self.server.store.decision_get(scope)['status']=='completed'):
|
||||
self.output(403, {'error':'credential scope rejected'})
|
||||
return False
|
||||
self.decision_scope = scope
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
@@ -219,6 +232,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
state['metrics'] = self.server.metrics.snapshot()
|
||||
state['eta_clock'] = dict(self.server.eta_clock.status)
|
||||
self.output(200, state)
|
||||
elif parts.path == '/api/decision-bridge-bindings' and not parts.query:
|
||||
self.output(200,self.server.store.decision_bindings())
|
||||
elif re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+(?:/(?:history|consultation))?',parts.path) and not parts.query:
|
||||
segments=parts.path.split('/')
|
||||
method = {'history':self.server.store.decision_history,'consultation':self.server.store.decision_consultation}.get(segments[-1]) if len(segments)==5 else self.server.store.decision_get
|
||||
self.output(200,method(segments[3]))
|
||||
elif parts.path == '/api/queue-health' and not parts.query:
|
||||
self.output(200,self.server.store.queue_health())
|
||||
elif parts.path == '/api/task-reports':
|
||||
@@ -246,6 +265,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.output(200, {'messages':reader(query['session_id'][0])})
|
||||
else:
|
||||
self.output(404, {'error':'not found'})
|
||||
except Conflict:
|
||||
self.output(409, {'error':'state conflict'})
|
||||
except (Invalid,ValueError):
|
||||
self.output(400, {'error':'invalid request'})
|
||||
except Exception:
|
||||
@@ -295,8 +316,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
data = json.loads(self.rfile.read(length))
|
||||
if not isinstance(data, dict):
|
||||
raise Invalid('object required')
|
||||
if getattr(self,'decision_scope',None) and self.command=='PATCH':
|
||||
allowed={'expected_revision','reason','title','summary','question','details','impact','recommendation','options','deadline','priority'}
|
||||
if data.keys()-allowed:
|
||||
self.output(403, {'error':'credential content scope rejected'})
|
||||
return
|
||||
store = self.server.store
|
||||
routes = {'/api/task-reports':store.task_report, '/api/attention':store.attention, '/api/eta-notifications/ack':store.eta_ack, '/api/decisions':store.decision, '/api/decision-notifications/ack':store.decision_ack,
|
||||
routes = {'/api/decision-notifications/submitting':store.decision_submitting, '/api/task-reports':store.task_report, '/api/attention':store.attention, '/api/eta-notifications/ack':store.eta_ack, '/api/decisions':store.decision, '/api/decision-notifications/ack':store.decision_ack,
|
||||
'/api/hook-notifications/submitting':store.hook_submitting,
|
||||
'/api/hook-notifications/coalesce':store.hook_coalesce, '/api/hooks':store.hook, '/api/hooks/escalate':store.hook_escalate, '/api/hooks/ack':store.hook_ack,
|
||||
'/api/sessions':store.session, '/api/session-requests':store.request,
|
||||
@@ -310,8 +336,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
result = store.integrate(self.path.split('/')[3],data)
|
||||
elif self.command == 'POST' and re.fullmatch(r'/api/eta-reviews/[A-Za-z0-9_.:-]+/complete',self.path):
|
||||
result = store.eta_complete(self.path.split('/')[3],data)
|
||||
elif self.command == 'POST' and re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+/respond',self.path):
|
||||
result = store.decision_respond(self.path.split('/')[3],data)
|
||||
elif self.command == 'PATCH' and re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+',self.path):
|
||||
result = store.decision_patch(self.path.split('/')[3],data)
|
||||
elif self.command == 'POST' and re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+/facts/[A-Za-z0-9_.:-]+/answer',self.path):
|
||||
segments=self.path.split('/')
|
||||
result = store.decision_fact_answer(segments[3],segments[5],data)
|
||||
elif self.command == 'POST' and re.fullmatch(r'/api/decisions/[A-Za-z0-9_.:-]+/(respond|review|complete|archive-receipt|bind-session|consultation|consultation-credential|facts)',self.path):
|
||||
actions = {'respond':store.decision_respond,'review':store.decision_review,'complete':store.decision_complete,
|
||||
'archive-receipt':store.decision_archive,'bind-session':store.decision_bind,
|
||||
'consultation':store.decision_consultation,'consultation-credential':store.decision_credential,'facts':store.decision_fact}
|
||||
if self.path.endswith('/bind-session') and data.get('session_id')==self.server.primary_session_id:
|
||||
raise Conflict('primary session cannot be bound')
|
||||
result = actions[self.path.split('/')[-1]](self.path.split('/')[3],data)
|
||||
elif self.command == 'POST' and self.path in routes:
|
||||
result = routes[self.path](data)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user