Repository navigation
feat(m1): Organization Scope Foundation — 权限地基与跨组织数据隔离 #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jhackuy
merged 20 commits into
feature/telegram-ui-v2
from
milestone/1-org-scope-foundation
Aug 21, 2026
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
d56f5e4
feat(m1-foundation): add canonical org scope shared helpers
f00ffe1
refactor(property-channel): re-export org scope helpers from new home
bd75c1f
feat(m1-migrations): property.org_id fail-closed backfill + NOT NULL …
eea6a5c
feat(m1-migrations): tenant.organization_id NOT NULL with canonical b…
de3f5d2
feat(m1-model-schema): tighten property/tenant model and schemas
81d0c1d
feat(m1-tenants): tenant endpoints full org scoped + PII redact preserve
b46acb6
feat(m1-domain-enforcement): 4 domain routers org scoped (leases/exp/…
1255313
feat(m1-properties-units): relax create-property gate + confirm units…
ff2c93c
test(m1-conftest): bootstrap org/membership for legacy fixtures
0330abb
test(m1-p0): cross-org 5-domain regression (income/expense/lease/repa…
f70c8a0
test(m1-adapt): align existing tests to new permission semantics
bbeef6d
fix(db): m1/m1b delete deleted_at filter + fail-closed; m1c expense.p…
28bea64
fix(schema): Property.organization_id NOT NULL; Expense.property_id F…
7cd3130
fix(auth): organization_scope shared HTTP translator; resolve split; …
f75be46
fix(api): 5 routers HTTP 404/403/409 contract; Tenant org immutable P…
54db34e
fix(api): expense dual-channel + pay/reverse idempotent; repairs cros…
8de4577
test(fix1): 3 test files Ruff + fixture + regression adapt for M1-FIX…
21108ac
fix(migration): org_id/property_id namespace + no DDL silent swallow
f7db3a0
fix(api): income update lease_id=None returns HTTP 409
38561ea
test(fix2): 4 regressions namespace isolation + ambiguity + income 40…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
227 changes: 227 additions & 0 deletions
227
alembic/versions/m1_a_property_org_not_null_backfill.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| """PASAY-MILESTONE-001: Property.organization_id backfill + NOT NULL harden (P1). | ||
|
|
||
| Revision ID: m1a000000001 | ||
| Revises: b5c6d7e8f9a0 | ||
| Create Date: 2026-08-21 | ||
|
|
||
| Owner contract: | ||
| 1. Legacy NULL Property.organization_id rows are auto-backfilled ONLY when the | ||
| ownership is UNIQUE AND UNAMBIGUOUS. | ||
| 2. If multiple Organizations exist and ownership cannot be uniquely determined, | ||
| FAIL CLOSED with specific Property IDs and candidate Organizations. | ||
| 3. NEVER take the first organization (oldest/newest/smallest id) guesswork IS forbidden. | ||
|
|
||
| Backfill rules (deterministic, ordered by priority): | ||
| A. If organizations table has exactly 1 row -> backfill all NULL rows to that id. | ||
| B. If 0 rows -> FAIL CLOSED (no org to backfill to). | ||
| C. If >1 rows: for every NULL property_id we try Property.created_by -> | ||
| Membership ACTIVE -> organization_id. If that resolves to exactly 1 org id | ||
| across all created_by memberships of the property, backfill it. Otherwise list | ||
| the property_id together with all candidate orgs and FAIL CLOSED. | ||
|
|
||
| Downgrade safety (Alembic downgrade MUST preserve column semantics): | ||
| - Before dropping NOT NULL we verify organization_id is still BIGINT + no type drift | ||
| via `sa.inspect` — otherwise refuse downgrade (FAIL CLOSED). | ||
| - The column remains BIGINT REFERENCES organizations(id) (nullable after downgrade). | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| from alembic import op | ||
| import sqlalchemy as sa | ||
| from sqlalchemy.engine import Connection | ||
|
|
||
| revision: str = "m1a000000001" | ||
| down_revision: str | None = "b5c6d7e8f9a0" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| class _AmbiguousBackfill(RuntimeError): | ||
| """Raised when legacy data cannot be uniquely backfilled (Owner rule #2).""" | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # UPGRADE helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def _gather_null_property_ids_without_org(conn: Connection) -> list[int]: | ||
| rows = conn.execute( | ||
| sa.text( | ||
| "SELECT p.id FROM properties p " | ||
| "WHERE p.organization_id IS NULL " | ||
| "ORDER BY p.id" | ||
| ) | ||
| ).all() | ||
| return [r[0] for r in rows] | ||
|
|
||
|
|
||
| def _count_organizations(conn: Connection) -> int: | ||
| return conn.execute(sa.text("SELECT COUNT(*) FROM organizations")).scalar() or 0 | ||
|
|
||
|
|
||
| def _only_org_id(conn: Connection) -> int: | ||
| row = conn.execute(sa.text("SELECT id FROM organizations ORDER BY id LIMIT 1")).one() | ||
| return row[0] | ||
|
|
||
|
|
||
| def _backfill_with_org_id(conn: Connection, org_id: int, target_ids: list[int]) -> None: | ||
| if not target_ids: | ||
| return | ||
| chunk_size = 500 | ||
| for i in range(0, len(target_ids), chunk_size): | ||
| chunk = target_ids[i : i + chunk_size] | ||
| conn.execute( | ||
| sa.text( | ||
| "UPDATE properties SET organization_id = :org WHERE id = ANY(:ids)" | ||
| ), | ||
| {"org": org_id, "ids": chunk}, | ||
| ) | ||
|
|
||
|
|
||
| def _resolve_created_by_org(conn: Connection, property_ids: list[int]) -> dict[int, list[int]]: | ||
| """For each NULL property, collect DISTINCT org_ids reachable via | ||
| property.created_by -> membership ACTIVE. Returns | ||
| {property_id: [sorted candidate org_ids]}. | ||
| """ | ||
| result: dict[int, list[int]] = {} | ||
| for pid in property_ids: | ||
| rows = conn.execute( | ||
| sa.text( | ||
| "SELECT DISTINCT m.organization_id " | ||
| "FROM properties p " | ||
| "JOIN memberships m ON m.user_id = p.created_by " | ||
| "WHERE p.id = :pid AND m.state = 'ACTIVE' " | ||
| "AND m.removed_at IS NULL " | ||
| "ORDER BY m.organization_id" | ||
| ), | ||
| {"pid": pid}, | ||
| ).all() | ||
| result[pid] = sorted(r[0] for r in rows) | ||
| return result | ||
|
|
||
|
|
||
| def _organizations_lookup(conn: Connection) -> dict[int, str]: | ||
| rows = conn.execute(sa.text("SELECT id, name FROM organizations ORDER BY id")).all() | ||
| return {r[0]: r[1] for r in rows} | ||
|
|
||
|
|
||
| def _fail_closed_ambiguous(conn: Connection, ambiguous: dict[int, list[int]]) -> None: | ||
| """Abort the upgrade deterministically with a deterministic error report for Owner review.""" | ||
| org_names = _organizations_lookup(conn) | ||
| lines = [ | ||
| "PASAY-MILESTONE-001 FAIL CLOSED: Property.org_id backfill is AMBIGUOUS for the", | ||
| "following legacy properties. Owner must decide their org manually.", | ||
| ] | ||
| for pid, candidates in sorted(ambiguous.items()): | ||
| cand_repr = ", ".join( | ||
| f"org_id={c} ({org_names.get(c, 'UNKNOWN')})" for c in candidates | ||
| ) or "NO candidates (0 ACTIVE membership for created_by)" | ||
| lines.append(f" - property_id={pid} candidates=[{cand_repr}]") | ||
| lines.append("Candidates Organizations (known):") | ||
| for oid, oname in sorted(org_names.items()): | ||
| lines.append(f" - org_id={oid} name={oname!r}") | ||
| raise _AmbiguousBackfill("\n".join(lines)) | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| conn = op.get_bind() | ||
| null_ids = _gather_null_property_ids_without_org(conn) | ||
| if not null_ids: | ||
| # Nothing to backfill — directly tighten NOT NULL. | ||
| pass | ||
| else: | ||
| org_count = _count_organizations(conn) | ||
| if org_count == 0: | ||
| # No orgs exist at all: this is either a fresh empty test DB with | ||
| # zero legacy data (Base.metadata.create_all path). In that case the | ||
| # properties rows wouldn't exist either → this case is actually empty set, | ||
| # should not reach here because null_ids=[]. If it does reach here fail | ||
| # deterministically: | ||
| _fail_closed_ambiguous(conn, {pid: [] for pid in null_ids}) | ||
| if org_count == 1: | ||
| # Case A: exactly one organization → auto-backfill deterministically. | ||
| _backfill_with_org_id(conn, _only_org_id(conn), null_ids) | ||
| else: | ||
| # Case C: >1 org → created_by membership resolution, per-property. | ||
| resolved = _resolve_created_by_org(conn, null_ids) | ||
| ambiguous: dict[int, list[int]] = {} | ||
| can_backfill: dict[int, int] = {} | ||
| for pid, orgs in resolved.items(): | ||
| if len(orgs) == 1: | ||
| can_backfill[pid] = orgs[0] | ||
| else: | ||
| ambiguous[pid] = orgs | ||
| if ambiguous: | ||
| _fail_closed_ambiguous(conn, ambiguous) | ||
| for org_id in sorted(set(can_backfill.values())): | ||
| ids_for_org = sorted(p for p, o in can_backfill.items() if o == org_id) | ||
| _backfill_with_org_id(conn, org_id, ids_for_org) | ||
| # -------------------- tighten NOT NULL constraint -------------------- | ||
| op.alter_column( | ||
| "properties", | ||
| "organization_id", | ||
| existing_type=sa.BigInteger(), | ||
| existing_nullable=True, | ||
| nullable=False, | ||
| ) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # DOWNGRADE (safe with sa.inspect audit gate) | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| conn = op.get_bind() | ||
| insp = sa.inspect(conn) | ||
| cols = {c["name"]: c for c in insp.get_columns("properties")} | ||
| col = cols.get("organization_id") | ||
| if col is None: | ||
| raise RuntimeError( | ||
| "PASAY-MILESTONE-001 downgrade FAIL CLOSED: " | ||
| "column properties.organization_id not present " | ||
| "(schema drift; refusing downgrade)." | ||
| ) | ||
| col_type = col.get("type") | ||
| # Must be BIGINT-compatible; NOT a different scalar type. | ||
| type_name = type(col_type).__name__ | ||
| if "BIGINT" not in type_name.upper() and "BigInteger" not in type_name: | ||
| try: | ||
| compiled = str(col_type.compile(dialect=conn.dialect)) | ||
| except Exception: # noqa: BLE001 | ||
| import logging | ||
|
|
||
| logging.getLogger("alembic.runtime.migration").warning( | ||
| "cannot compile organization_id column type in downgrade audit", exc_info=True | ||
| ) | ||
| compiled = "" | ||
| if "BIGINT" not in compiled.upper(): | ||
| raise RuntimeError( | ||
| f"PASAY-MILESTONE-001 downgrade FAIL CLOSED: " | ||
| f"properties.organization_id type drifted to {type_name!r} " | ||
| f"(compiled={compiled!r}); expected BIGINT. " | ||
| f"Refusing downgrade to prevent numeric semantic loss." | ||
| ) | ||
| # Foreign key sanity: the column must still reference organizations(id). | ||
| fks = insp.get_foreign_keys("properties") | ||
| fk_match = any( | ||
| set(fk.get("constrained_columns") or []) == {"organization_id"} | ||
| and (fk.get("referred_table") == "organizations") | ||
| and set(fk.get("referred_columns") or []) == {"id"} | ||
| for fk in fks | ||
| ) | ||
| if not fk_match: | ||
| raise RuntimeError( | ||
| "PASAY-MILESTONE-001 downgrade FAIL CLOSED: " | ||
| "FK properties.organization_id -> organizations(id) " | ||
| "drifted or missing; refusing downgrade." | ||
| ) | ||
| # Semantics audit passed; relax NOT NULL. | ||
| op.alter_column( | ||
| "properties", | ||
| "organization_id", | ||
| existing_type=sa.BigInteger(), | ||
| existing_nullable=False, | ||
| nullable=True, | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.