diff --git a/docs/migration/bubble-auth-and-export.md b/docs/migration/bubble-auth-and-export.md index 01932ab..51f0f21 100644 --- a/docs/migration/bubble-auth-and-export.md +++ b/docs/migration/bubble-auth-and-export.md @@ -177,3 +177,22 @@ records remain private. The new bundle retains all71spaces and the existing graph/grant/fidelity gates.18body links and one attachment flag are still unreconciled. Captured Visual bytes do not authorize copying memberships or productive import. Public proof: docs/release/bubble-visual-preparation-2026-10-31.json. + +## Private access reconciliation + +`scripts/audit-bubble-access.py ORIGINAL NEW_PRIVATE_DIRECTORY` inventories the +retained source ownership, membership and visibility fields without proposing +target grants. It requires private, hash-bound source snapshots and refuses +source drift, symlinks, public files, hard links, existing output and output +inside the original bundle or this repository. The JSON and escaped, network-free +HTML review are private; stdout contains aggregate counts and hashes only. + +The October 31 review is at +`~/.config/coulomb-social/auth/exports/access-review-20261031/index.html`. +It covers 70 fresh spaces, 245 article records and one inventory-only space. +Three distinct source account references remain to be linked through authoritative +identity evidence. Nine creator references are unresolved. A missing source +membership field is unknown, not an empty list. Source role names and visibility +values are preserved for review; no equivalence with target owner/member/viewer +permissions is assumed. The accepted ReichelAG owner grant remains evidence for +that cohort alone. See `docs/release/bubble-access-audit-2026-10-31.json`. diff --git a/docs/release/bubble-access-audit-2026-10-31.json b/docs/release/bubble-access-audit-2026-10-31.json new file mode 100644 index 0000000..ff0ce96 --- /dev/null +++ b/docs/release/bubble-access-audit-2026-10-31.json @@ -0,0 +1,131 @@ +{ + "schema": "coulomb.bubble-access-audit-result.v1", + "spaces": { + "records": 70, + "field_states": { + "Created By": { + "unresolved_reference": 5, + "observed": 64, + "missing": 1 + }, + "crew_list_user": { + "observed": 69, + "missing": 1 + }, + "members_list_user": { + "observed": 47, + "missing": 23 + }, + "guests_list_user": { + "missing": 69, + "observed": 1 + } + }, + "option_fields": { + "isprivate_boolean": { + "present": 69, + "missing": 1, + "distinct_observed_values": 2 + }, + "addnewusersasmember_boolean": { + "present": 20, + "missing": 50, + "distinct_observed_values": 2 + }, + "addnewusersasguest_boolean": { + "present": 21, + "missing": 49, + "distinct_observed_values": 2 + } + } + }, + "articles": { + "records": 245, + "field_states": { + "Created By": { + "observed": 239, + "unresolved_reference": 4, + "missing": 2 + }, + "_creator_user": { + "missing": 10, + "observed": 235 + }, + "_owner_user": { + "observed": 243, + "missing": 2 + }, + "moders_list_user": { + "missing": 71, + "observed": 174 + } + }, + "option_fields": { + "access_option_visibility": { + "present": 243, + "missing": 2, + "distinct_observed_values": 2 + } + } + }, + "known_spaces": 71, + "fresh_space_records": 70, + "inventory_only_spaces": 1, + "distinct_source_principals": 3, + "target_identity_mappings_established": 0, + "target_grants_proposed": 0, + "production_changed": false, + "source_visibility_semantics_verified": false, + "source_sha256": { + "spaces": "ae20b0cfd1705f486c0b67ef48ac84b396f953cbcef09f9a8f07d07a66f1bf71", + "articles": "f33967c79c0f8a60a7d504f5d5e4082f8bc9e3c34f6a3160cb200fd380831d62", + "retained_spaces": "92038249ec2e4eed4c8d61ffcdbe030232cebc52e59ba005baee506a16930550" + }, + "source_report_sha256": "75fcd87d17ea0c5042e903efd60e65839da7716e5399ff431a345486ddd10f01", + "recorded_at": "2026-10-31T14:05:30.738728+00:00", + "workplan_task": "CSOC-WP-0014-T01", + "task_status": "progress", + "accepted_mapping_scope_evidence": "docs/release/reichelag-imported-2026-10-06.md", + "validation": { + "focused_tests_passed": 13, + "full_gate": "make check passed", + "junit": { + "tests": 583, + "errors": 0, + "failures": 0, + "skipped": 0 + }, + "source_snapshots_unchanged": true, + "private_output_hashes_verified": true, + "output_modes_verified": true + }, + "statehub_readback": { + "task_id": "0002882d-f156-5a5d-b524-d04793f4684d", + "status": "progress", + "access_audit_description_synced": true, + "fix_consistency_exit_code": 0, + "automation_errors": 0, + "assessment_failures": 0, + "warnings": 6 + }, + "output_files": [ + { + "path": "access-inventory.json", + "sha256": "d4f97ccc55867361048325f7227721a50a59bcf70ea1af4dfa3db48e8e703cb2" + }, + { + "path": "index.html", + "sha256": "6c175577f8a34c7bda78f86461d68d2c2167708970517f417bc89d1fde37436b" + } + ], + "implementation_files": [ + { + "path": "scripts/audit-bubble-access.py", + "sha256": "6590a19e54b77521e31455944b085f92e784e2b85737579cb14caaeb8538807b" + }, + { + "path": "tests/test_bubble_access_audit.py", + "sha256": "392a799d62828c7508a1b25133d9c1c8de01959b7f1fec90ae737a7cefce7883" + } + ] +} diff --git a/docs/release/goal-progress-audit-2026-10-31.md b/docs/release/goal-progress-audit-2026-10-31.md index fc34226..2aa3ee4 100644 --- a/docs/release/goal-progress-audit-2026-10-31.md +++ b/docs/release/goal-progress-audit-2026-10-31.md @@ -161,3 +161,14 @@ checks pass. This advances the source checklist to14of33done, not an effort percentage. Source exceptions, identity/grant mapping, links/attachments and productive transfer/verdicts remain open; the rough remaining-effort estimate has not been recalculated from these counts. + +## Access reconciliation checkpoint + +WP-0014-T01 now has a private, source-hash-bound access inventory covering the +70 fresh spaces and 245 articles, while retaining the inventory-only 71st space. +It identifies three distinct source principals and nine unresolved creator +references, preserving missing access fields as unknown. Existing ReichelAG +admission does not establish all-space identity or role mappings. The task remains +progress: this closes an evidence gap inside the task, not productive permission +reconciliation. The checklist stays 14 of 33 done, and the remaining-effort +estimate is unchanged. Evidence: `bubble-access-audit-2026-10-31.json`. diff --git a/scripts/audit-bubble-access.py b/scripts/audit-bubble-access.py new file mode 100644 index 0000000..2c93065 --- /dev/null +++ b/scripts/audit-bubble-access.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Prepare an offline private access inventory; never infer target identities or grants.""" + +import argparse +import hashlib +import html +import json +import os +import re +import stat +import sys +from collections import Counter +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from coulomb_social.apps.spaces.bubble_transfer import normalize_ref # noqa: E402 + +FIELDS = { + 'spaces': {'Created By': False, 'crew_list_user': True, + 'members_list_user': True, 'guests_list_user': True}, + 'articles': {'Created By': False, '_creator_user': False, + '_owner_user': False, 'moders_list_user': True}, +} +OPTIONS = {'spaces': ['isprivate_boolean', 'addnewusersasmember_boolean', + 'addnewusersasguest_boolean'], + 'articles': ['access_option_visibility']} + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def private_read(path): + if any(p.is_symlink() for p in (path, *path.parents)): + raise ValueError('source symlink') + info = path.stat() + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() + or info.st_mode & 0o077 or info.st_nlink != 1 or info.st_size > 16 * 1024**2): + raise ValueError('private bounded regular source required') + return path.read_bytes() + + +def reference(value): + try: + result = normalize_ref(value) + except (ValueError, TypeError): + return None + return result if re.fullmatch(r'[0-9]+x[0-9]+', result) else None + + +def field_state(row, field, many): + if field not in row: + return {'state': 'missing', 'principals': []} + raw = row[field] + if raw is None: + return {'state': 'null', 'raw': raw, 'principals': []} + if many and not isinstance(raw, list): + return {'state': 'invalid_container', 'raw': raw, 'principals': []} + if not many and not isinstance(raw, str): + return {'state': 'invalid_container', 'raw': raw, 'principals': []} + values = raw if many else [raw] + refs = [reference(v) for v in values] + return {'state': ('empty' if not values else 'unresolved_reference' + if None in refs else 'observed'), + 'raw': raw, 'principals': sorted(set(r for r in refs if r is not None)), + 'unresolved_count': sum(r is None for r in refs)} + + +def audit(inputs): + """Source values remain private; the separate summary contains aggregate counts only.""" + records = {} + principals = set() + summary = {} + for kind in FIELDS: + source = inputs[kind] + if not isinstance(source, dict) or any(not isinstance(v, dict) for v in source.values()): + raise ValueError('record dictionaries required') + records[kind] = [] + counters = {f: Counter() for f in FIELDS[kind]} + for key, row in sorted(source.items()): + fields = {f: field_state(row, f, many) for f, many in FIELDS[kind].items()} + for f, value in fields.items(): + counters[f][value['state']] += 1 + principals.update(value['principals']) + records[kind].append({'source_id': key, 'fields': fields, + 'options': {f: {'present': f in row, 'value': row.get(f)} + for f in OPTIONS[kind]}}) + summary[kind] = { + 'records': len(source), 'field_states': counters, + 'option_fields': {f: { + 'present': sum(f in row for row in source.values()), + 'missing': sum(f not in row for row in source.values()), + 'distinct_observed_values': len({json.dumps(row[f], sort_keys=True) + for row in source.values() if f in row}), + } for f in OPTIONS[kind]}, + } + retained = inputs['retained_spaces'] + if not isinstance(retained, dict): + raise ValueError('retained inventory required') + known = set(retained) | set(inputs['spaces']) + summary.update({'known_spaces': len(known), 'fresh_space_records': len(inputs['spaces']), + 'inventory_only_spaces': len(set(retained) - set(inputs['spaces'])), + 'distinct_source_principals': len(principals), + 'target_identity_mappings_established': 0, + 'target_grants_proposed': 0, 'production_changed': False, + 'source_visibility_semantics_verified': False}) + return {'schema': 'coulomb.bubble-access-inventory.v1', 'summary': summary, + 'source_principals': sorted(principals), 'records': records, + 'inventory_only_space_ids': sorted(set(retained) - set(inputs['spaces'])), + 'limitations': [ + 'Missing fields are unknown, not empty grants.', + 'Source roles and visibility are observed values, not target permissions.', + 'Existing one-space owner admission does not map every source identity.', + 'This capture does not prove complete or current provider membership.', + 'No target identity, role mapping or import manifest is created.']} + + +def review_html(report): + rows = [] + for kind, records in report['records'].items(): + for row in records: + data = json.dumps(row, ensure_ascii=False, indent=2) + rows.append('
' + html.escape(kind + ': ' + row['source_id']) + + '
' + html.escape(data) + '
') + return ('' + '' + 'Private source access inventory' + '

Private source access inventory

Observed source metadata only. ' + 'No target identities or grants have been inferred.

'
+            + html.escape(json.dumps(report['summary'], indent=2)) + '
' + ''.join(rows) + '').encode() + + +def prepare(source, out): + raw = {kind: private_read(source / 'source-capture' / (kind + '.json')) + for kind in [*FIELDS, 'retained_spaces']} + receipt_raw = private_read(source / 'reconciliation.json') + receipt = json.loads(receipt_raw) + if (receipt['schema'] != 'coulomb.bubble-review-bundle.v1' + or receipt['status'] != 'completed_offline_preparation'): + raise ValueError('completed review bundle required') + hashes = {k: sha(v) for k, v in raw.items()} + if any(receipt['source_sha256'].get(k) != v for k, v in hashes.items()): + raise ValueError('source digest mismatch') + report = audit({k: json.loads(v) for k, v in raw.items()}) + report['source_sha256'] = hashes + report['source_report_sha256'] = sha(receipt_raw) + if out.exists() or any(p.is_symlink() for p in (out, *out.parents)): + raise ValueError('new private output required') + parent = out.parent.stat() + if (parent.st_uid != os.getuid() or parent.st_mode & 0o077 + or out.resolve().is_relative_to(Path(__file__).resolve().parents[1]) + or out.resolve().is_relative_to(source.resolve())): + raise ValueError('private output outside repository and source required') + for kind, data in raw.items(): + if private_read(source / 'source-capture' / (kind + '.json')) != data: + raise ValueError('source changed during audit') + if private_read(source / 'reconciliation.json') != receipt_raw: + raise ValueError('source report changed during audit') + out.mkdir(mode=0o700) + outputs = {'access-inventory.json': json.dumps(report, indent=2).encode(), + 'index.html': review_html(report)} + for name, data in outputs.items(): + with os.fdopen(os.open(out / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb') as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + return {'schema': 'coulomb.bubble-access-audit-result.v1', **report['summary'], + 'source_sha256': hashes, 'source_report_sha256': sha(receipt_raw), + 'output_sha256': {k: sha(v) for k, v in outputs.items()}} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('source', type=Path) + parser.add_argument('output', type=Path) + args = parser.parse_args() + try: + result = prepare(args.source, args.output) + except Exception: + print('Access audit refused; no identity or grant inferred.', file=sys.stderr) + return 1 + print(json.dumps(result)) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_bubble_access_audit.py b/tests/test_bubble_access_audit.py new file mode 100644 index 0000000..3f75ee1 --- /dev/null +++ b/tests/test_bubble_access_audit.py @@ -0,0 +1,110 @@ +"""Missing access metadata must never turn into empty or approved permissions.""" + +import hashlib +import importlib.util +import json +from pathlib import Path + +import pytest + +SPEC = importlib.util.spec_from_file_location( + 'bubble_access_audit', Path(__file__).resolve().parents[1] / 'scripts/audit-bubble-access.py') +audit = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(audit) + + +def inputs(): + return {'spaces': {'1x1': {'crew_list_user': ['LOOKUP__User__9x9'], + 'members_list_user': [], 'isprivate_boolean': False}}, + 'articles': {'2x2': {'_owner_user': '9x9', 'Created By': 'unresolved'}}, + 'retained_spaces': {'1x1': {}, '3x3': {}}} + + +def test_aliases_collapse_but_no_grants_or_empty_missing_inference(): + report = audit.audit(inputs()) + assert report['source_principals'] == ['9x9'] + fields = report['records']['spaces'][0]['fields'] + assert fields['members_list_user']['state'] == 'empty' + assert fields['guests_list_user']['state'] == 'missing' + created_by = report['records']['articles'][0]['fields']['Created By'] + assert created_by['state'] == 'unresolved_reference' + assert report['inventory_only_space_ids'] == ['3x3'] + assert report['summary']['known_spaces'] == 2 + assert report['summary']['target_grants_proposed'] == 0 + assert report['summary']['target_identity_mappings_established'] == 0 + assert '9x9' not in json.dumps(report['summary']) + + +@pytest.mark.parametrize('raw,expected', [(None, 'null'), ('9x9', 'invalid_container'), + ([], 'empty'), ([{}], 'unresolved_reference'), + (['9x9', None], 'unresolved_reference')]) +def test_field_states(raw, expected): + result = audit.field_state({'members_list_user': raw}, 'members_list_user', True) + assert result['state'] == expected + assert result['raw'] == raw + + +def test_private_html_escapes_source_and_disables_network(): + data = inputs() + data['articles']['2x2']['Created By'] = '' + result = audit.review_html(audit.audit(data)) + assert b'