diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 53fadb0..f61c983 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -145,6 +145,16 @@ jobs: check "login /login" 200 "http://127.0.0.1:8000/login" check "/api gateway alive" 200 "http://127.0.0.1:8000/api" check "unknown slug 404" 404 "http://127.0.0.1:8000/no-such-page-xyzzy" + check "org-scoped CMS page" 200 "http://127.0.0.1:8000/ci-smoke-page" + + # The seeded page (org_id = the site org) must actually DISPATCH — its body has to render, not a + # 404 shell. This is the guard for the org_id write-stamp ↔ read-scope path: a mismatch 404s here. + curl -s "http://127.0.0.1:8000/ci-smoke-page" >page.html || true + if grep -q "CI_SMOKE_PAGE_OK" page.html; then + echo "PASS org-scoped CMS page body dispatched" + else + echo "FAIL org-scoped CMS page body not found (org write/read mismatch?)"; echo " body: $(head -c 300 page.html)"; fail=1 + fi # the /api gateway must answer with the JSON envelope contract (result key present), even for # an empty/refused message — proves the webservice layer is wired, not just returning a page. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0893c..1c40a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +### Changed +- **`org_id` is now stamped on every tenant write, like `created_by`.** `Tiger_Model_Table` auto-stamps + `org_id` from the current org (`setOrg()`, wired from the authenticated membership in the Authorization + plugin) on any table that has the column — so content is *owned* instead of left at the empty default, + with no per-module boilerplate. An explicit `org_id` still wins; system/global writes (no org) keep `''`. + Fixes CMS pages + blog articles, which never stored `org_id`. +- **Public read dispatch resolves the site org.** `Tiger_Model_Org::siteOrgId()` (configured + `tiger.site.org_id`, else the founding org) is what page/article dispatch scopes to; `_orgScope('')` + resolves to it, so a public read finds the site's own content, not just shared `''` rows. The read scope + stays `[org, '']`, so nothing 404s during the transition. This is the exact seam a future multi-site + module overrides per request host (`setSiteOrgId()`) — the base stays single-site and dumb. +- Migration `0033` backfills legacy `page.org_id = ''` content to the site org. + ### Added - **TigerSEO — Phase 1 (the head registry).** A new bundled `seo` module makes the data Tiger already collects actually reach the ``. Core hygiene: the public PUMA layout now renders through TigerZF's diff --git a/ci/smoke-boot.php b/ci/smoke-boot.php index 96043be..4ad10f3 100644 --- a/ci/smoke-boot.php +++ b/ci/smoke-boot.php @@ -30,4 +30,30 @@ exit(1); } -echo "smoke boot OK — tiger-core v" . Tiger_Version::VERSION . ", org=$orgs user=$users\n"; +// Seed a PUBLISHED CMS page stamped with the site org, so the HTTP smoke can prove public page dispatch +// resolves org-owned content (the org_id write-stamp + read-scope path). Idempotent-ish: fresh CI DB. +$siteOrg = Tiger_Model_Org::siteOrgId(); +if ($siteOrg === '') { + fwrite(STDERR, "smoke: could not resolve the site org\n"); + exit(1); +} +Tiger_Model_Table::setOrg($siteOrg); // the base model will stamp org_id from this +$pageModel = new Tiger_Model_Page(); +$pageModel->insert([ + 'type' => Tiger_Model_Page::TYPE_PAGE, + 'page_key' => 'ci-smoke-page', + 'slug' => 'ci-smoke-page', + 'locale' => 'en', + 'title' => 'CI Smoke Page', + 'body' => 'CI_SMOKE_PAGE_OK', + 'format' => 'html', + 'status' => Tiger_Model_Page::STATUS_PUBLISHED, + 'published_at' => null, +]); +$seeded = $db->fetchRow("SELECT org_id, status FROM page WHERE slug = 'ci-smoke-page'"); +if (!$seeded || (string) $seeded['org_id'] !== (string) $siteOrg) { + fwrite(STDERR, "smoke: seeded page missing or not org-stamped (got org_id=" . ($seeded['org_id'] ?? 'none') . ", want $siteOrg)\n"); + exit(1); +} + +echo "smoke boot OK — tiger-core v" . Tiger_Version::VERSION . ", org=$orgs user=$users; seeded page org_id=" . $seeded['org_id'] . "\n"; diff --git a/library/Tiger/Controller/Plugin/Authorization.php b/library/Tiger/Controller/Plugin/Authorization.php index 95071a2..b582fdf 100644 --- a/library/Tiger/Controller/Plugin/Authorization.php +++ b/library/Tiger/Controller/Plugin/Authorization.php @@ -95,6 +95,7 @@ protected function _resolveRole() } Tiger_Model_Table::setActor($identity->user_id); // created_by/updated_by flow + Tiger_Model_Table::setOrg((string) ($identity->org_id ?? '')); // org_id (tenant) flow $role = self::ROLE_AUTHENTICATED; if (!empty($identity->org_id)) { diff --git a/library/Tiger/Controller/Plugin/PageDispatch.php b/library/Tiger/Controller/Plugin/PageDispatch.php index 1037a62..d6a89dc 100644 --- a/library/Tiger/Controller/Plugin/PageDispatch.php +++ b/library/Tiger/Controller/Plugin/PageDispatch.php @@ -46,7 +46,9 @@ public function routeShutdown(Zend_Controller_Request_Abstract $request) return; // the root belongs to IndexController (the landing) } $locale = defined('LANG') ? LANG : 'en'; - $orgId = ''; // global public pages for now (host->org mapping is future) + $orgId = Tiger_Model_Org::siteOrgId(); // the org owning this public site (root org on a stock + // install; a multi-site module resolves host->org). The + // read scope is [org, ''], so shared '' content still shows. try { // Only real pages answer at the site root — articles/posts route under /blog. diff --git a/library/Tiger/Model/Org.php b/library/Tiger/Model/Org.php index 40d6cdb..d4dcf17 100644 --- a/library/Tiger/Model/Org.php +++ b/library/Tiger/Model/Org.php @@ -23,6 +23,52 @@ class Tiger_Model_Org extends Tiger_Model_Table protected $_name = 'org'; protected $_primary = 'org_id'; + /** @var string|null the current-site org id, resolved once per request */ + private static $_siteOrgId = null; + + /** + * The current site's org — the tenant that owns the PUBLIC site being served. Public read dispatch + * (CMS pages, blog articles) scopes to this; content writes are stamped from the authenticated org + * (Tiger_Model_Table::setOrg). Resolution: the configured `tiger.site.org_id` (recorded at install / + * settable in Site settings), else the **founding org** (the oldest) as a heuristic. NB: org hierarchy + * (`parent_org_id`) is future — right now every org is a root, so "root" isn't a distinguishing query; + * the site org is the platform's founding org. A multi-site module overrides this per request from the + * request host (setSiteOrgId). Cached per request. + * + * @return string the site org id, or '' if no org exists yet (a pre-install install) + */ + public static function siteOrgId() + { + if (self::$_siteOrgId !== null) { + return self::$_siteOrgId; + } + $cfg = Zend_Registry::isRegistered('Zend_Config') ? Zend_Registry::get('Zend_Config') : null; + $t = $cfg ? $cfg->get('tiger') : null; + $s = $t ? $t->get('site') : null; + $id = ($s && $s->get('org_id')) ? (string) $s->org_id : ''; + if ($id === '') { + try { + $m = new self(); + $row = $m->fetchRow($m->activeSelect()->order('created_at ASC')->limit(1)); // the founding org + $id = $row ? (string) $row->org_id : ''; + } catch (Throwable $e) { + $id = ''; + } + } + return self::$_siteOrgId = $id; + } + + /** + * Override the current-site org (a multi-site module resolves this from the request host, early). + * + * @param string $orgId + * @return void + */ + public static function setSiteOrgId($orgId) + { + self::$_siteOrgId = (string) $orgId; + } + /** * Find an org by its URL-safe slug (the human/route-facing identifier). * diff --git a/library/Tiger/Model/Page.php b/library/Tiger/Model/Page.php index a630ba3..2b51015 100644 --- a/library/Tiger/Model/Page.php +++ b/library/Tiger/Model/Page.php @@ -233,9 +233,18 @@ public function datatable(array $opts) return ['total' => $total, 'filtered' => $filtered, 'rows' => $db->fetchAll($pageSel)]; } - /** The org scope for a cascade lookup: [, ''] (deduped; '' = global). */ + /** + * The org scope for a cascade lookup: [, ''] (deduped; '' = shared-across-sites fallback, which + * loses to a real org via `ORDER BY org_id DESC`). A blank org means "the current site" — it resolves + * to Tiger_Model_Org::siteOrgId() so a public read (which passes '') scopes to the site's own content + * now that content carries a real org_id, not just the shared '' rows. + */ protected function _orgScope($orgId) { - return array_values(array_unique([(string) $orgId, ''])); + $orgId = (string) $orgId; + if ($orgId === '') { + $orgId = Tiger_Model_Org::siteOrgId(); + } + return array_values(array_unique([$orgId, ''])); } } diff --git a/library/Tiger/Model/Table.php b/library/Tiger/Model/Table.php index 976ce7f..9206e96 100644 --- a/library/Tiger/Model/Table.php +++ b/library/Tiger/Model/Table.php @@ -75,6 +75,38 @@ public static function actor() return self::$_actor; } + /** + * The "current org" — the tenant an insert is credited to, stamped into `org_id` the same way the + * actor is stamped into created_by/updated_by. Request-wide (static). The auth layer calls setOrg() + * per request with the user's active org; null leaves org_id at its column default ('' = platform/ + * global — a system row, a shipped translation). The multi-site module overrides this per domain. + * + * @var string|null + */ + private static $_org = null; + + /** + * Set the current org (an org_id). Auth calls this per request from the active membership; a + * multi-site module overrides it from the request host. An explicitly passed org_id always wins. + * + * @param string|null $orgId the acting org's id, or null for platform/global scope + * @return void + */ + public static function setOrg($orgId) + { + self::$_org = $orgId; + } + + /** + * The current org_id, or null in a platform/global context. + * + * @return string|null the acting org's id, or null + */ + public static function org() + { + return self::$_org; + } + /** * Insert a row: mint the UUID PK, stamp created_at/updated_at and (if an actor * is set) created_by/updated_by. Any of these you pass explicitly wins. @@ -107,6 +139,13 @@ public function insert(array $data) } } + // Tenant stamp: a row on an org_id table is credited to the current org, so content is owned + // rather than left at the '' default. Only when an org is set (an authenticated request) and the + // caller didn't pass org_id explicitly — system/global writes (no org) keep the column default. + if (self::$_org !== null && $this->_hasColumn('org_id') && !array_key_exists('org_id', $data)) { + $data['org_id'] = self::$_org; + } + parent::insert($data); // Return the UUID we generated — NOT lastInsertId(), which is empty for a diff --git a/migrations/0033_page_org_backfill.php b/migrations/0033_page_org_backfill.php new file mode 100644 index 0000000..3d4424d --- /dev/null +++ b/migrations/0033_page_org_backfill.php @@ -0,0 +1,24 @@ + [ + function ($db) { + $org = Tiger_Model_Org::siteOrgId(); + if ($org !== '') { + $db->update('page', ['org_id' => $org], "org_id = ''"); + } + }, + ], + 'down' => [], +]; diff --git a/modules/blog/controllers/IndexController.php b/modules/blog/controllers/IndexController.php index ca8d16c..0df371e 100644 --- a/modules/blog/controllers/IndexController.php +++ b/modules/blog/controllers/IndexController.php @@ -50,7 +50,7 @@ public function indexAction() */ public function viewAction() { - $post = $this->_posts->resolveArticle((string) $this->getParam('slug', ''), $this->_locale(), ''); + $post = $this->_posts->resolveArticle((string) $this->getParam('slug', ''), $this->_locale(), Tiger_Model_Org::siteOrgId()); if (!$post) { throw new Zend_Controller_Action_Exception('Article not found', 404); } diff --git a/modules/blog/models/Taxonomy.php b/modules/blog/models/Taxonomy.php index 211c173..6f86659 100644 --- a/modules/blog/models/Taxonomy.php +++ b/modules/blog/models/Taxonomy.php @@ -206,10 +206,13 @@ public function slugify($text) return trim((string) $text, '-'); } - /** org scope: tenant row overrides global (''), mirroring Tiger_Model_Page. */ + /** org scope: tenant row overrides shared (''); a blank org means "the current site". Mirrors Tiger_Model_Page. */ protected function _orgScope($orgId) { $orgId = (string) $orgId; + if ($orgId === '') { + $orgId = Tiger_Model_Org::siteOrgId(); + } return $orgId !== '' ? [$orgId, ''] : ['']; } }