Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
118 lines
6 KiB
Python
118 lines
6 KiB
Python
"""Bounded provider recovery: preview, stale-state guard, disable, audit and replay."""
|
|
import hashlib,json,re
|
|
|
|
ACTION='keycape.factor.recovery'
|
|
class RecoveryError(Exception):pass
|
|
|
|
def fingerprint(snapshot):
|
|
return hashlib.sha256(json.dumps(snapshot,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
|
|
|
def recover(store, request):
|
|
from contextlib import nullcontext
|
|
with store.operation_lock() if hasattr(store,"operation_lock") else nullcontext():
|
|
return _recover(store,request)
|
|
|
|
def _recover(store, request):
|
|
for key in ('user','serial','realm','actor','reference'):
|
|
value=request.get(key)
|
|
if not isinstance(value,str) or not value or len(value)>200 or not re.fullmatch(r'[A-Za-z0-9_.@:/-]+',value):raise RecoveryError('invalid_request')
|
|
if request['realm']!=store.realm:raise RecoveryError('wrong_realm')
|
|
snapshot=store.snapshot(request['user'],request['serial'])
|
|
if snapshot is None:raise RecoveryError('factor_not_owned_by_target')
|
|
version=fingerprint(snapshot)
|
|
base={k:request[k] for k in ('user','serial','realm','actor','reference')}
|
|
base.update(scope='shared_identity_across_applications',operation='disable_one_factor',version=version)
|
|
if not request.get('apply'):
|
|
return dict(base,status='preview',active=snapshot['active'],changes_applied=False)
|
|
if request.get('identity_verified') is not True:raise RecoveryError('identity_verification_required')
|
|
expected=request.get('expected_version')
|
|
if not isinstance(expected,str) or not re.fullmatch(r'[0-9a-f]{64}',expected):raise RecoveryError('preview_required')
|
|
prior=store.receipt(request['reference'])
|
|
if prior:
|
|
if any(prior.get(k)!=request[k] for k in ('user','serial','realm','actor')) or prior.get('version')!=expected:raise RecoveryError('reference_conflict')
|
|
if not snapshot['active']:
|
|
if fingerprint(dict(snapshot,active=True))!=expected:raise RecoveryError('factor_changed_after_recovery')
|
|
if not prior['complete']:store.record(base,expected,True)
|
|
return dict(base,status='recovered',changes_applied=False,replayed=True)
|
|
if prior['complete']:raise RecoveryError('factor_changed_after_recovery')
|
|
if version!=expected:raise RecoveryError('stale_preview')
|
|
if not snapshot['active']:raise RecoveryError('factor_already_inactive')
|
|
store.record(base,expected,False) # An audit outage prevents mutation.
|
|
store.disable(request['user'],request['serial'],expected)
|
|
current=store.snapshot(request['user'],request['serial'])
|
|
if current is None or current['active'] or fingerprint(dict(current,active=True))!=expected:raise RecoveryError('readback_failed')
|
|
store.record(base,expected,True)
|
|
return dict(base,status='recovered',changes_applied=True,replayed=False)
|
|
|
|
class ProviderStore:
|
|
def __init__(self, app, realm='coulomb'):
|
|
self.app=app;self.realm=realm
|
|
def operation_lock(self):
|
|
from contextlib import contextmanager
|
|
@contextmanager
|
|
def locked():
|
|
from privacyidea.models import db
|
|
from sqlalchemy import text
|
|
if db.engine.dialect.name != 'postgresql':
|
|
yield;return
|
|
with db.engine.connect() as connection:
|
|
connection.execute(text('SELECT pg_advisory_lock(4912040036)'))
|
|
try:yield
|
|
finally:connection.execute(text('SELECT pg_advisory_unlock(4912040036)'))
|
|
return locked()
|
|
def serials(self,user):
|
|
from privacyidea.lib.token import get_tokens
|
|
from privacyidea.lib.user import User
|
|
values=get_tokens(user=User(user,self.realm))
|
|
if len(values)>20:raise RecoveryError('too_many_factors')
|
|
return [token.token.serial for token in values]
|
|
def _token(self,user,serial):
|
|
from privacyidea.lib.token import get_tokens
|
|
from privacyidea.lib.user import User
|
|
values=get_tokens(user=User(user,self.realm),serial=serial)
|
|
if len(values)!=1:return None
|
|
return values[0].token
|
|
def snapshot(self,user,serial):
|
|
token=self._token(user,serial)
|
|
if token is None:return None
|
|
data=token.get_vars()
|
|
return {k:data[k] for k in ('id','serial','user_id','resolver','realms','active','rollout_state','tokentype')}
|
|
def disable(self,user,serial,expected):
|
|
from privacyidea.models import db,Token
|
|
token=self._token(user,serial)
|
|
if token is None:raise RecoveryError('factor_not_owned_by_target')
|
|
locked=db.session.query(Token).filter(Token.id==token.id).with_for_update().one()
|
|
db.session.refresh(locked)
|
|
if fingerprint(self.snapshot(user,serial))!=expected:
|
|
db.session.rollback();raise RecoveryError('stale_preview')
|
|
locked.active=False
|
|
db.session.commit()
|
|
def _audit(self):
|
|
from privacyidea.lib.audit import getAudit
|
|
return getAudit(self.app.config)
|
|
def receipt(self,reference):
|
|
page=self._audit().search({'action':ACTION,'action_detail':reference},page_size=1,sortorder='desc')
|
|
if not page.auditdata:return None
|
|
row=page.auditdata[0]
|
|
return {'user':row['user'],'serial':row['serial'],'realm':row['realm'],'actor':row['administrator'],'version':row['info'],'complete':bool(row['success'])}
|
|
def record(self,request,version,complete):
|
|
audit=self._audit()
|
|
audit.log({'action':ACTION,'action_detail':request['reference'],'user':request['user'],'serial':request['serial'],'realm':request['realm'],'administrator':request['actor'],'info':version,'success':bool(complete)})
|
|
audit.finalize_log()
|
|
# Do not treat a silently failed audit append as durable evidence.
|
|
row=self.receipt(request['reference'])
|
|
if row is None or any(row.get(k)!=request[k] for k in ('user','serial','realm','actor')) or row['version']!=version or row['complete']!=bool(complete):raise RecoveryError('audit_unavailable')
|
|
|
|
def main():
|
|
import contextlib,io,logging,sys
|
|
request=json.load(sys.stdin);result={'success':False}
|
|
with contextlib.redirect_stdout(io.StringIO()),contextlib.redirect_stderr(io.StringIO()):
|
|
try:
|
|
logging.disable(logging.CRITICAL)
|
|
from privacyidea.app import create_app
|
|
app=create_app(config_name='production',silent=True)
|
|
with app.app_context():result=dict(recover(ProviderStore(app),request),success=True)
|
|
except RecoveryError as e:result['failure']=str(e)
|
|
except Exception:result['failure']='provider_operation_unavailable'
|
|
print(json.dumps(result));return 0 if result['success'] else 1
|
|
if __name__=='__main__':raise SystemExit(main())
|