Issue #198 — Implementation Plan
Public Portal + Archives-led UX + Hard Multi-Tenancy + Governance/Hardening
The Archive and Heritage Group (Pty) Ltd Date: 2026-02-28 Framework: v2.8.2 | Plugins: 79 Tracking Issue: ArchiveHeritageGroup/atom-extensions-catalog#198 Related Issues: #185 (Security DevOps), #197 (Security Hardening)
Executive Summary
This plan addresses issue #198's five EPICs through a phased delivery over 6 milestones. Codebase analysis reveals the system is 60-70% ready — substantial infrastructure exists for multi-tenancy (ahgMultiTenantPlugin v1.2.0), search (ahgDiscoveryPlugin + ahgSemanticSearchPlugin), rights enforcement (3-layer model), and research workflows (ahgResearchPlugin v3.0.0 with 87 tables). The primary gaps are hard isolation (uploads/search/cache/audit lack tenant_id), search explainability UI, bulk edit operations, and security hardening (unsafe unserialize(), missing upload MIME validation).
Current State Summary
What Already Exists (Strength Map)
| Capability | Maturity | Plugin(s) |
|---|---|---|
| Tenant CRUD, domain routing, user roles, branding | 80% | ahgMultiTenantPlugin (DISABLED) |
| GLAM-aware display profiles + dynamic faceting | 95% | ahgDisplayPlugin |
| Multi-strategy discovery search | 80% | ahgDiscoveryPlugin |
| Template-driven description forms (14 field types, 5 seeds) | 100% | ahgFormsPlugin |
| Publish gates (8 rule types, editor bridge) | 100% | ahgWorkflowPlugin |
| Change tracking + field-level audit | 100% | ahgWorkflowPlugin + ahgAuditTrailPlugin |
| Duplicate detection (6 algorithms, real-time check) | 90% | ahgDedupePlugin |
| 3-layer rights (ACL + security clearance + embargo) | 95% | ahgRightsPlugin + ahgExtendedRightsPlugin + ahgSecurityClearancePlugin |
| Research platform (RO-Crate, annotations, bookings) | 95% | ahgResearchPlugin (87 tables) |
| Access/publish/reproduction requests | 90% | ahgAccessRequestPlugin + ahgRequestToPublishPlugin + ahgCartPlugin |
| REST API v2 + GraphQL + webhooks | 90% | ahgAPIPlugin + ahgGraphQLPlugin |
| Backup (5 presets, manifest tracking) | 80% | ahgBackupPlugin |
| Preservation (checksums, fixity, PREMIS, PRONOM) | 90% | ahgPreservationPlugin |
| Heritage discovery portal + contributor system | 85% | ahgHeritagePlugin (58 tables) |
What's Missing (Gap Map)
| Gap | Impact | EPIC |
|---|---|---|
Upload paths not tenant-scoped (/uploads/r/{objectId}/) |
Data leakage | C1 |
| Elasticsearch — single global index, no tenant filter | Search leakage | C2 |
| Cache keys have no tenant namespace | Cache contamination | C3 |
Audit tables missing tenant_id column |
Compliance breach | C4 |
| No search ranking explainability UI | UX gap | A2 |
| No hierarchy breadcrumb + context blocks on record view | UX gap | A3 |
| No unified restrictions banner (rights/embargo/POPIA) | UX gap | A3 |
| No bulk edit operations (update N records at once) | Workflow gap | B2 |
| No external authority linking (Wikidata/VIAF/GeoNames) | Quality gap | B3 |
unserialize() without allowed_classes in 9 files |
RCE risk | D4 |
No finfo MIME validation on uploads |
Malware risk | D1 |
No health endpoints (/api/health) |
Ops gap | E2 |
| No automated backup scheduling | DR gap | E3 |
| No job retry/timeout (nohup pattern) | Reliability | E1 |
Milestone Plan
M0: Immediate Security Fixes (Week 1)
Priority: CRITICAL — Do before any feature work Scope: Issues #197 partial, #185 partial
These are zero-feature-debt items that reduce risk immediately.
M0.1: Serialization Hardening (D4)
| Files to modify: | File | Line(s) | Change |
|---|---|---|---|
ahgMuseumPlugin/lib/Services/Getty/GettyCacheService.php |
62, 198, 233 | Add ['allowed_classes' => false] to all unserialize() calls; migrate to json_encode/json_decode |
|
ahgSemanticSearchPlugin/lib/Services/SemanticSearchService.php |
255, 358, 409 | Replace unserialize() with json_decode() |
|
ahgSettingsPlugin/modules/ahgSettings/actions/handlers/*.php |
Multiple | Add ['allowed_classes' => false] |
|
ahgInformationObjectManagePlugin |
~1100 | Add ['allowed_classes' => false] |
|
ahgThemeB5Plugin |
48, 58, 110 | Add ['allowed_classes' => false] |
New file: Move Getty cache from /tmp/getty_cache/ to /var/cache/atom/getty/ with 0700 permissions.
M0.2: Upload Hardening (D1)
| Files to modify: | File | Change |
|---|---|---|
ahgAPIPlugin/modules/apiv2/actions/fileUploadAction.class.php |
Add finfo_file() MIME validation; add basename() on type parameter to prevent path traversal; add file size limit |
|
ahgDisplayPlugin/modules/digitalobject/actions/uploadAction.class.php |
Add finfo_file() MIME check alongside existing size check |
New file: atom-framework/src/Services/FileValidationService.php
- Extension allowlist (configurable via
ahg_settings) - MIME magic-bytes validation via
finfo - Max file size enforcement
- Called by all upload handlers
M0.3: API Path Traversal Fix
File: ahgAPIPlugin/modules/apiv2/actions/fileUploadAction.class.php
- Add
$type = basename($request->getParameter('type'))before building upload path
Deliverable: Security fix release via ./bin/release patch "Security hardening: serialization, uploads, API path traversal"
M1: Hard Multi-Tenancy Foundation (Weeks 2-5)
Scope: EPIC C (complete) + EPIC A1 (tenant-scoped routing/branding) Plugin: ahgMultiTenantPlugin (currently disabled)
M1.1: Upload Isolation (C1)
Schema change: Add tenant_id INT NULL to digital_object table (nullable for backward compat — existing objects get NULL = "default tenant")
| Files to modify: | File | Change |
|---|---|---|
atom-framework/src/Services/Write/StandaloneDigitalObjectWriteService.php |
Upload path: /uploads/r/{tenantCode}/{objectId}/ when tenant active; fallback to /uploads/r/{objectId}/ when NULL |
| New files: | File | Purpose |
|---|---|---|
ahgMultiTenantPlugin/database/migrations/002_tenant_isolation.sql |
ALTER digital_object ADD tenant_id; ALTER audit tables ADD tenant_id; ALTER cache infrastructure |
|
ahgMultiTenantPlugin/lib/Services/TenantFileService.php |
Tenant-aware upload path resolution, cleanup on tenant deletion, migration of existing uploads |
Data migration CLI: php symfony tenant:migrate-uploads — assigns existing digital objects to default tenant, creates tenant subdirectories, moves files
M1.2: Search Isolation (C2)
Strategy: Single index with mandatory tenant filter (simpler than per-tenant indexes; avoids reindex on tenant creation)
| Files to modify: | File | Change |
|---|---|---|
ahgCorePlugin/lib/Services/ElasticsearchService.php |
Add tenant_id field to all indexed documents; apply TenantQueryFilter::getElasticsearchFilter() to ALL queries |
|
ahgMultiTenantPlugin/lib/Filter/TenantQueryFilter.php |
Update getElasticsearchFilter() to use tenant_id (not just repository.id) |
|
atom-framework/src/Services/Search/SearchService.php |
Inject tenant context into all search calls; apply filter before query execution |
Reindex CLI: php symfony tenant:reindex — populates tenant_id field in ES for all existing documents
M1.3: Cache Isolation (C3)
| Files to modify: | File | Change |
|---|---|---|
atom-framework/src/Services/CacheService.php |
Prefix cache keys with tenant_{id}:; add clearTenantCache(int $tenantId) method |
|
ahgDisplayPlugin/lib/Services/DynamicFacetService.php |
Pass tenant context to cache keys |
M1.4: Audit Isolation (C4)
Schema change: Add tenant_id INT NULL + request_id VARCHAR(36) NULL to:
ahg_audit_logahg_audit_accessahg_audit_authentication
| Files to modify: | File | Change |
|---|---|---|
ahgAuditTrailPlugin/lib/Services/AhgAuditService.php |
Accept tenant_id parameter; auto-populate from TenantContext if available |
M1.5: Tenant-Scoped Branding (A1)
Already implemented: TenantBranding service + domain routing in ahgMultiTenantPlugin. Gap: Need to activate and test.
| Files to modify: | File | Change |
|---|---|---|
ahgMultiTenantPlugin/config/ahgMultiTenantPluginConfiguration.class.php |
Ensure onContextLoadFactories hook runs TenantResolver + TenantContext initialization |
Verification tests:
# Cross-tenant leakage test
curl -H "Host: tenant-a.heritage.example.com" https://localhost/browse | grep -c "tenant-b-data"
# Should return 0
# Upload isolation test
# Create file as tenant A, try to access as tenant B → expect 404
# Cache isolation test
# Set cache as tenant A, read as tenant B → expect NULL
# Audit test
mysql -u root archive -e "SELECT DISTINCT tenant_id FROM ahg_audit_log WHERE tenant_id IS NOT NULL"
Deliverable: ./bin/release minor "Hard multi-tenancy: upload/search/cache/audit isolation"
M2: Public Portal VNext — Search & Browse (Weeks 6-9)
Scope: EPIC A2, A3 partial Plugins: ahgDisplayPlugin (bug fixes only — it's stable), ahgDiscoveryPlugin, ahgThemeB5Plugin overrides
M2.1: Hierarchy-Aware Search Ranking (A2)
Strategy: Boost ES scores for records whose parent fonds/series matches query terms.
| Files to modify: | File | Change |
|---|---|---|
ahgDiscoveryPlugin/lib/Services/DiscoverySearchService.php |
Add function_score query with match on parent_titles field; boost factor 1.5 for fonds-level matches |
Index change: Add parent_titles field to ES mapping (denormalized from MPTT hierarchy)
M2.2: Search Explainability Panel (A2)
| New files: | File | Purpose |
|---|---|---|
ahgDiscoveryPlugin/modules/discovery/templates/_searchExplain.php |
Sidebar panel showing: matched fields, boost factors, query expansion terms used |
| Files to modify: | File | Change |
|---|---|---|
ahgDiscoveryPlugin/lib/Services/DiscoverySearchService.php |
Add ?explain=true parameter to ES queries; parse _explanation from ES response |
UI: "Why this result?" expandable panel per search result showing:
- Which fields matched (title, scope_and_content, creator, etc.)
- Which synonyms/expansions contributed
- Hierarchy context boost applied
M2.3: Record View — Hierarchy Breadcrumb (A3)
| New files: | File | Purpose |
|---|---|---|
ahgDisplayPlugin/modules/display/templates/_hierarchyBreadcrumb.php |
Breadcrumb trail: Fonds > Series > Sub-series > File > Item | |
ahgDisplayPlugin/modules/display/templates/_childrenList.php |
Collapsible children list with counts |
Implementation: Use MPTT (lft/rgt) columns on information_object to walk the tree. Laravel QB query with ancestor_slugs for breadcrumb links.
M2.4: Record View — Restrictions Banner (A3)
| New files: | File | Purpose |
|---|---|---|
ahgDisplayPlugin/modules/display/templates/_restrictionsBanner.php |
Unified banner showing: rights statement, embargo status, POPIA flag, security classification, access conditions |
Implementation: Call RightsService, ExtendedRightsService, SecurityClearanceService to build restriction summary. Display as Bootstrap 5 alert (color-coded: red=blocked, yellow=restricted, green=open).
M2.5: Provenance & Integrity Display (A3)
| New files: | File | Purpose |
|---|---|---|
ahgDisplayPlugin/modules/display/templates/_provenanceBlock.php |
Show: acquisition source, digitization notes, checksum/fixity status, last verified date |
Implementation: Query ahg_audit_log for provenance chain + preservation_checksum for fixity data.
Deliverable: ./bin/release minor "Public portal: search explainability, hierarchy breadcrumb, restrictions banner"
M3: Description UX & Authority Workbench (Weeks 10-14)
Scope: EPIC B (complete) Plugins: ahgFormsPlugin (minor), ahgWorkflowPlugin, ahgDedupePlugin, NEW: ahgBulkEditPlugin
M3.1: Bulk Edit with Diff Preview (B2)
New plugin: ahgBulkEditPlugin
| Database tables: | Table | Purpose |
|---|---|---|
ahg_bulk_edit_job |
Job tracking: query, fields, values, user, status, progress, error_log | |
ahg_bulk_edit_snapshot |
Pre-edit snapshots for rollback (object_id, field, old_value, job_id) |
New service: BulkEditService.php
planBulkEdit(array $objectIds, array $fieldChanges, string $culture): array
→ returns [{object_id, field, old_value, new_value}] preview
executeBulkEdit(int $jobId, int $userId): void
→ async: iterates objects, applies changes via StandaloneInformationObjectWriteService
→ stores snapshots in ahg_bulk_edit_snapshot
→ logs each change to ahg_audit_log with correlation_id
→ emits workflow events
rollbackBulkEdit(int $jobId, int $userId): void
→ restores old_values from snapshots
→ logs rollback event
| Routes: | URL | Purpose |
|---|---|---|
/admin/bulk-edit |
Bulk edit wizard (select objects → choose fields → preview diff → confirm) | |
/admin/bulk-edit/preview |
AJAX: show diff preview | |
/admin/bulk-edit/execute |
AJAX: start async job | |
/admin/bulk-edit/status/:jobId |
AJAX: poll progress | |
/admin/bulk-edit/rollback/:jobId |
Rollback a completed bulk edit | |
/admin/bulk-edit/history |
Browse past bulk edit jobs |
Integration points:
- ChangeSummaryService: reuse for diff computation
- AhgAuditService: log all changes with
correlation_id - WorkflowEventService: emit
bulk_edit_started,bulk_edit_completed,bulk_edit_rolled_back
M3.2: Authority Workbench — Merge with Rollback (B3)
Extend ahgDedupePlugin:
| New tables: | Table | Purpose |
|---|---|---|
ahg_authority_merge_log |
Merge operations: source_ids, target_id, merge_map (JSON), user, created_at | |
ahg_authority_merge_snapshot |
Pre-merge state: entity type, entity_id, full data JSON |
New service: AuthorityMergeService.php (in ahgDedupePlugin)
previewMerge(int $targetId, array $sourceIds): array
→ returns field-by-field comparison: which values kept, which discarded
→ identifies linked records (events, relations) that will be re-pointed
executeMerge(int $targetId, array $sourceIds, array $fieldChoices, int $userId): int
→ takes pre-merge snapshots
→ merges chosen fields into target
→ re-points all events, relations, contact info from sources to target
→ marks sources as redirects (or deletes)
→ logs merge in ahg_authority_merge_log + ahg_audit_log
rollbackMerge(int $mergeLogId, int $userId): bool
→ restores snapshots
→ re-creates source actors
→ re-points events/relations back
→ logs rollback
| Routes: | URL | Purpose |
|---|---|---|
/admin/authority/merge/:targetId |
Merge wizard (select fields, preview, confirm) | |
/admin/authority/merge-history |
Browse past merges | |
/admin/authority/rollback/:mergeId |
Rollback a merge |
M3.3: External Authority Linking (B3)
Extend ahgSemanticSearchPlugin or create new service:
New service: ExternalAuthorityService.php
searchWikidata(string $query, string $entityType): array
→ SPARQL query to wikidata.org, returns [{qid, label, description, sameAs}]
searchViaf(string $query): array
→ REST query to viaf.org, returns [{viafId, name, sources}]
searchGeoNames(string $query): array
→ REST query to api.geonames.org, returns [{geonameId, name, country, coordinates}]
linkAuthority(int $actorId, string $source, string $externalId): void
→ stores in new `ahg_authority_link` table
→ pulls enrichment data (dates, alternate names)
→ logs in audit trail
getLinkedAuthorities(int $actorId): array
→ returns all external links for actor
New table: ahg_authority_link (actor_id, source, external_id, external_label, cached_data JSON, linked_at, linked_by)
UI: Authority detail page shows "External Links" panel with search + link capability.
Deliverable: ./bin/release minor "Bulk edit with rollback, authority merge workbench, external authority linking"
M4: Digital Object Delivery & Public Requests (Weeks 15-17)
Scope: EPIC A4, A5
M4.1: Derivative Policy Engine (A4)
New table: ahg_derivative_policy (id, scope_type, scope_id, access_level, policy JSON, created_at)
scope_type: global, repository, collection, objectaccess_level: public, authenticated, researcher, admin- Policy JSON:
{thumb: true, reference: true, master: false, watermark: true, max_dimension: 1200}
New service: DerivativePolicyService.php (in atom-framework)
resolvePolicy(int $objectId, ?int $userId): array
→ cascading resolution: object → collection → repository → global
→ factors in user role + security clearance
→ returns {thumb, reference, master, watermark, max_dimension, streaming_allowed}
canAccess(int $objectId, string $derivativeType, ?int $userId): bool
→ resolvePolicy() → check if requested derivative is allowed
applyPolicy(int $objectId, string $derivativeType, ?int $userId): array
→ returns {allowed, watermark, max_dimension, reason}
Integration: IIIF manifest generation, digital object download handler, and API file endpoints all call DerivativePolicyService::canAccess() before serving.
M4.2: Unified Requester Dashboard (A5)
New module in ahgAccessRequestPlugin (or new lightweight plugin):
New file: ahgAccessRequestPlugin/modules/security/templates/requesterDashboardSuccess.php
Implementation: Aggregates across 3 request types:
access_request(ahgAccessRequestPlugin) — restricted material accessrequest_to_publish(ahgRequestToPublishPlugin) — publication requestsahg_order(ahgCartPlugin) — reproduction orders
UI: Tabbed dashboard showing:
- All requests with status (pending/approved/denied/completed)
- Filter by type, date, status
- Click through to individual request detail
Route: /my-requests (public, authenticated users only)
M4.3: Automated Rights Gate Before Submission (A5)
Extend PublishGateService to also act as a "request gate":
New method:
evaluateRequestGate(int $objectId, string $requestType, int $userId): array
→ checks: user has researcher role, object not embargoed, object has reproduction rights, user signed terms
→ returns [{rule, status, message}]
UI: Before any request form submission, AJAX call to evaluateRequestGate(). If blockers exist, show them with instructions (e.g., "This object is under embargo until 2028-01-01").
M5: Security & Ops Hardening (Weeks 18-22)
Scope: EPIC D (remaining) + EPIC E (complete) Related issues: #185, #197
M5.1: Outbound HTTP Policy (D2)
New service: atom-framework/src/Services/HttpClientService.php
get(string $url, array $options): Response
post(string $url, array $data, array $options): Response
// Built-in protections:
// - RFC1918/link-local IP blocking (configurable allowlist)
// - Redirect limit (max 3)
// - Timeout enforcement (connect: 5s, total: 30s)
// - Request logging (tenant_id + request_id)
// - DNS resolution check before connect
Files to modify: Replace raw curl_*() and file_get_contents() in:
- ElasticsearchService (localhost exempt from IP check)
- GettySparqlService
- WebhookService (user-provided URLs get full validation)
- DOI plugin (DataCite API)
- Federation plugin (OAI-PMH endpoints)
M5.2: Shell Execution Policy (D3)
New service: atom-framework/src/Services/ProcessService.php
run(string $command, array $args, array $options): ProcessResult
→ $command must be in allowlist (python3, clamdscan, sf, tesseract, etc.)
→ all $args passed through escapeshellarg()
→ working directory constrained via realpath()
→ timeout enforcement
→ no secrets in argv (use env vars)
Files to modify: Replace any remaining shell_exec() calls with ProcessService::run().
M5.3: Health Endpoints (E2)
| New files: | File | Purpose |
|---|---|---|
ahgAPIPlugin/modules/apiv2/actions/healthAction.class.php |
GET /api/health — full health check |
|
ahgAPIPlugin/modules/apiv2/actions/healthLiveAction.class.php |
GET /api/health/live — liveness probe |
|
ahgAPIPlugin/modules/apiv2/actions/healthReadyAction.class.php |
GET /api/health/ready — readiness probe |
Health checks:
- Database connection (SELECT 1)
- Elasticsearch ping
- Upload directory writable
- PHP memory/version
- Disk space (warn if >90%)
- Queue worker running (if applicable)
Response:
{
"status": "healthy",
"checks": {
"database": {"status": "up", "latency_ms": 2},
"elasticsearch": {"status": "up", "latency_ms": 15},
"disk": {"status": "warning", "usage_pct": 92},
"uploads": {"status": "up", "writable": true}
},
"version": "2.8.2"
}
M5.4: Job Queue Improvements (E1)
Strategy: Keep existing nohup/CLI pattern but add reliability:
New table: ahg_job_queue (id, job_type, payload JSON, status, attempts, max_attempts, last_attempt_at, timeout_seconds, started_at, completed_at, error_log, created_by)
New service: JobQueueService.php
enqueue(string $jobType, array $payload, array $options): int
→ options: {max_attempts: 3, timeout: 3600, priority: 'normal'}
processNext(): bool
→ picks oldest pending job, marks in_progress, executes
→ on failure: increment attempts, schedule retry with backoff (1min, 5min, 15min)
→ on timeout: mark failed, allow retry
getStatus(int $jobId): array
heartbeat(int $jobId): void // worker calls every 30s
detectStale(int $timeoutMinutes): array // find jobs without heartbeat
CLI: php symfony queue:worker --daemon — persistent worker process
Systemd unit file: /etc/systemd/system/atom-worker.service
M5.5: Backup Automation (E3)
| Files to modify: | File | Change |
|---|---|---|
ahgBackupPlugin/lib/Services/BackupService.php |
Add retentionCleanup(int $keepDays) method; add verifyBackup(int $backupId) method |
| New files: | File | Purpose |
|---|---|---|
ahgBackupPlugin/lib/task/backupScheduleTask.class.php |
CLI: php symfony backup:schedule — configurable daily/weekly |
|
ahgBackupPlugin/lib/task/backupCleanupTask.class.php |
CLI: php symfony backup:cleanup --older-than=30d |
|
ahgBackupPlugin/lib/task/backupVerifyTask.class.php |
CLI: php symfony backup:verify --last — test restore to temp dir |
Cron configuration:
# Daily DB backup at 2 AM
0 2 * * * cd /usr/share/nginx/archive && php symfony backup:create --preset=db --quiet
# Weekly full backup at 3 AM Sunday
0 3 * * 0 cd /usr/share/nginx/archive && php symfony backup:create --preset=full --quiet
# Daily cleanup (keep 30 days)
0 4 * * * cd /usr/share/nginx/archive && php symfony backup:cleanup --older-than=30d --quiet
M5.6: Audit Log Retention & Tamper Detection (E-cross)
| Files to modify: | File | Change |
|---|---|---|
ahgAuditTrailPlugin/lib/Services/AhgAuditService.php |
Add sequence_hash column computation (HMAC chain); add archiveLogs(int $olderThanDays) method |
New table: ahg_audit_archive — cold storage for audit records older than 1 year
New CLI: php symfony audit:verify-integrity — verify HMAC chain is unbroken
Deliverable: ./bin/release minor "Security hardening: HTTP policy, health endpoints, job queue, backup automation"
M6: Documentation & Governance (Weeks 23-24)
Scope: Issue #197 documentation deliverables
New documentation files (in atom-extensions-catalog/docs/):
| File | Purpose |
|---|---|
SECURITY_MODEL.md |
Trust boundaries, secrets management, upload/outbound HTTP policy, serialization policy |
MULTI_TENANCY.md |
Hard isolation architecture, tenant lifecycle, data flow diagrams |
JOBS_AND_QUEUES.md |
Worker model, retry policy, idempotency rules, monitoring |
BACKUP_AND_DR.md |
Backup presets, retention, restore runbooks, RTO/RPO targets |
UPGRADE_PLAYBOOK.md |
Migration order, rollback procedures, version compatibility |
PR_REVIEW_CHECKLIST.md |
Security + documentation checklist for PR reviews |
PLUGIN_DOC_TEMPLATE.md |
Standard doc skeleton for all plugins (README structure) |
Feature overview documents (per plugin docs/):
ahgMultiTenantPlugin/docs/AtoM_Heratio_Multi_Tenancy_Feature_Overview.md+.docxahgBulkEditPlugin/docs/AtoM_Heratio_Bulk_Edit_Feature_Overview.md+.docx
File Summary
| Milestone | New Files | Modified Files | New Tables | Schema Changes |
|---|---|---|---|---|
| M0 | 1 (FileValidationService) | 5-9 | 0 | 0 |
| M1 | 3 (migration, TenantFileService, reindex CLI) | 6 | 0 | 4 ALTER TABLE (add tenant_id) |
| M2 | 5 (templates/partials) | 2 | 0 | 1 ES mapping change |
| M3 | 8 (plugin + services + templates) | 3 | 4 (bulk_edit, merge, authority_link) | 0 |
| M4 | 3 (templates + service) | 3 | 1 (derivative_policy) | 0 |
| M5 | 8 (services + actions + CLI) | 4 | 2 (job_queue, audit_archive) | 1 ALTER TABLE |
| M6 | 9 (documentation) | 0 | 0 | 0 |
| TOTAL | 37 | 23-27 | 7 | 5-6 |
Dependency Graph
M0 (Security Fixes)
└─→ M1 (Multi-Tenancy)
├─→ M2 (Search & Browse)
│ └─→ M4 (Digital Delivery & Requests)
└─→ M3 (Description UX)
└─→ M4
└─→ M5 (Ops Hardening)
└─→ M6 (Documentation)
M0 is a prerequisite for everything (security baseline). M1 must complete before M2 (search needs tenant isolation). M2 and M3 can run in parallel after M1. M4 depends on both M2 (UI patterns) and M3 (form patterns). M5 can start during M3/M4 (independent ops work). M6 is the final wrap-up.
Risk Register
| Risk | Impact | Mitigation |
|---|---|---|
Schema changes to digital_object break base AtoM |
High | ADD COLUMN only (nullable), no existing column changes; test on PSIS first |
| ES reindex takes too long for production | Medium | Run during maintenance window; incremental reindex by tenant |
| Bulk edit corrupts data | High | Pre-edit snapshots mandatory; rollback tested before release |
| Multi-tenant activation breaks existing single-tenant | High | Feature-flag tenant_enabled=false by default; null tenant_id = all-access |
| Getty cache migration loses data | Low | Keep old format readable; migration is one-way JSON conversion |
| ahgAuditTrailPlugin is LOCKED | Medium | Only ADD columns (no schema changes to existing columns); coordinate with owner |
| Disk space (99% full) | High | Backup retention + cleanup before M5; archive old backups to NAS |
Issues to Close After Completion
| Issue | Close After | Comment |
|---|---|---|
| #185 | M5 complete | Security DevOps addressed by M0 + M5 |
| #197 | M6 complete | Security + Documentation hardening addressed by M0 + M5 + M6 |
| #198 | M6 complete | Full ROADMAP addressed by M0-M6 |
Duplicate Issues to Close Now
| Issues 187-191 are exact duplicates of 192-196. Close the duplicates: | Close | Duplicate Of | Title |
|---|---|---|---|
| #192 | #187 | Integrity: Fixity engine | |
| #193 | #188 | Integrity: Append-only validation ledger | |
| #194 | #189 | Integrity: Retention policy engine | |
| #195 | #190 | Integrity: Real-time dashboard | |
| #196 | #191 | Integrity: REST API for validation jobs |
Verification Plan
M0 Verification
# Serialization: grep for unserialize without allowed_classes
grep -rn "unserialize(" atom-ahg-plugins/ --include="*.php" | grep -v "allowed_classes"
# Should return 0 results
# Upload MIME: test with crafted file
echo "#!/bin/bash" > /tmp/test.jpg
curl -X POST -F "file=@/tmp/test.jpg" /api/v2/file-upload -H "Authorization: Bearer <key>"
# Should return 400 Bad Request (MIME mismatch)
M1 Verification
# Upload isolation
ls /mnt/nas/heratio/archive/r/ | head -5 # Should show tenant subdirectories
# Search isolation
curl -s "localhost:9200/archive_qubitinformationobject/_search?q=*&size=1" | python3 -m json.tool | grep tenant_id
# Should show tenant_id field
# Cache isolation
mysql -u root archive -e "SELECT setting_key FROM ahg_settings WHERE setting_key LIKE 'tenant_%'"
# Audit isolation
mysql -u root archive -e "SELECT COUNT(*) FROM ahg_audit_log WHERE tenant_id IS NOT NULL"
M2 Verification
# Search explainability
curl -s "https://psis.theahg.co.za/discovery/search?q=test&explain=true" -b cookies.txt | grep "explanation"
# Hierarchy breadcrumb
curl -s "https://psis.theahg.co.za/test-slug" -b cookies.txt | grep "breadcrumb"
M5 Verification
# Health endpoint
curl -s https://psis.theahg.co.za/api/health | python3 -m json.tool
# Job worker
systemctl status atom-worker
# Backup schedule
crontab -l | grep backup
The Archive and Heritage Group (Pty) Ltd https://github.com/ArchiveHeritageGroup