"""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("SET lock_timeout = '5s'")) 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 identity=User(user,self.realm) if not identity.resolver or identity.uid in (None,''):raise RecoveryError('account_not_found') values=get_tokens(user=identity) 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 identity=User(user,self.realm) if not identity.resolver or identity.uid in (None,''):return None values=get_tokens(user=identity,serial=serial) if len(values)!=1:return None from privacyidea.models import TokenOwner token=values[0].token if TokenOwner.query.filter_by(token_id=token.id).count()!=1:raise RecoveryError("shared_factor_not_supported") return 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,TokenOwner token=self._token(user,serial) if token is None:raise RecoveryError('factor_not_owned_by_target') if db.engine.dialect.name=="postgresql": from sqlalchemy import text db.session.execute(text("SET LOCAL lock_timeout = '5s'")) locked=db.session.query(Token).filter(Token.id==token.id).with_for_update().one() db.session.query(TokenOwner).filter(TokenOwner.token_id==token.id).with_for_update(of=TokenOwner).all() 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())