Separate organizations, and give members their organization's projects

An organization's work is separate from every other organization's, and the
separation is now one module rather than a property each route remembers.

`tenancy.py` resolves the caller's scope in a single place. A member is pinned
to the organization on their own assignment, expressed as a concrete identifier
rather than a flag, so "no filter" is unreachable except for a platform
administrator. Every read and write resolves through it, so there is no per-route
variation to drift.

Within an organization, every account sees every project. The brief is explicit
that two people working on different books both see both, and assignment says
who is *working* on a project rather than who may *know* it exists. This
diverges from `app.can_access_project`, which restricts visibility to assigned
members; the divergence, and what it means if row-level security is ever
switched on, are recorded in migrations/README.md because that would otherwise
be a silent tenant-internal regression no test here would catch.

Two rules, both learned by getting them wrong first:

- A read naming another organization is ignored, because refusing it would
  confirm the identifier exists. A write is refused rather than retargeted,
  because a project landing in an organization the caller did not ask for is
  somewhere they will not look.
- A missing project and a foreign one get the same error, so an identifier cannot
  be probed across a boundary.

Projects: the `projects` table, its statuses, and the project-scoped arm of
`user_roles` were already in the schema and unused. No migration.

The dashboard is one server-scoped call, `/api/workspace`, rather than several
assembled in the browser — pieces from different tenants that nobody notices are
not a failure mode worth shipping. Filters narrow the list already in the page,
so switching cannot mix two fetches, and an empty list is distinguishable from a
failed one.

**A sign-in lockout, found by the integration suite.** An assignment is a second
active `user_roles` row, and `_resolve_assignment` refused an account with more
than one. So assigning anybody to anything made them unable to authenticate —
locked out of the page the assignment was meant to show them. `_sync_assignment`
had the same defect. Six queries were scope-blind; three were harmless only
because the assignment role is named `viewer` and never matched a tenant role
name, which is luck rather than a property, so a structural test now pins the
predicate on every one. The guard against two *tenant* rows survives, narrowed to
the ambiguity that actually matters.

Also fixed: `get_project_by_slug` cast `slug::text`, making citext's
case-insensitivity a case-*sensitive* comparison that appeared to work everywhere
else. An assignment grants the `viewer` project role, since no `member` role
exists at project scope; it is a named constant, not a query literal, because the
project workflow is not defined yet.

410 tests pass with the integration suite against a real database; 386 without.
This commit is contained in:
umbra audio
2026-09-28 11:34:37 -07:00
parent b12c702c66
commit ed72f61975
23 changed files with 3996 additions and 55 deletions

View File

@@ -99,6 +99,48 @@ UMBRATRANS_TEST_DSN="postgresql:///umbratrans_scratch?host=/run/postgresql&user=
UMBRATRANS_TEST_DATABASE_IS_DISPOSABLE=1 uv run pytest umbratrans_web
```
## Tenant separation and projects
An organization's work is separate from every other organization's. Within an
organization, **every account sees every project**; a `project`-scoped assignment
says who is *working* on one, not who may look at it.
The rule lives in one module, `umbratrans_web/tenancy.py`, and is not
per-route: a member is *pinned* to the organization on their own assignment, so
naming a different one in a query string or body cannot widen it. A pinned scope
is a concrete identifier rather than a flag, so "no filter" is unreachable except
for a platform administrator.
Two rules worth holding onto, because both were got wrong first:
- **A read naming another organization is ignored; a write is refused.** Refusing
a read would confirm the identifier exists. Silently retargeting a write would
put the project in an organization the caller did not ask for and would not
look in — `tenancy.resolve_target` exists for exactly that.
- **A missing project and a foreign one get the same error**, so an identifier
cannot be probed for existence across a tenant boundary.
An assignment writes a second `user_roles` row. That is why every tenancy query
carries `TENANT_ROW_PREDICATE`, and why the review gate's own SQL in
`migrations/README.md` needed a `project_id IS NULL` predicate. Read that section
before approving the migration; the failure mode is an assigned member who cannot
sign in, and it was found by the integration suite and not by any unit test.
### Deploying this needs no migration
The `projects` table, its `(id, organization_id)` unique constraint, the
`on_hold`/`released`/`archived` statuses, and the project-scoped arm of
`user_roles` were all already in the schema. This change uses them and alters
nothing, so a deploy is a restart with no database step:
```sh
systemctl --user restart umbratrans-web
```
The project assignment writes the `viewer` project role, because there is no
`member` role at project scope. It is a constant,
`PostgresAccountRepository.PROJECT_ASSIGNMENT_ROLE`, pending a project workflow.
## The original two open questions, for the record
### 1. A database (resolved: a dedicated one)

View File

@@ -461,10 +461,14 @@ def create_app(
from .routes_auth import router as auth_router
from .routes_setup import router as setup_router
from .routes_uadmin import router as uadmin_router
from .routes_workspace import router as workspace_router
app.include_router(setup_router, prefix="/api/setup", tags=["setup"])
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(uadmin_router, prefix="/api/uadmin", tags=["uadmin"])
# Mounted at /api rather than /api/auth: this is the member's own view, not
# something they authenticate *to*, and it needs no administrative role.
app.include_router(workspace_router, prefix="/api/workspace", tags=["workspace"])
@app.get("/api/health", tags=["health"])
def health() -> HealthResponse:

View File

@@ -153,6 +153,140 @@ facts now. **Open** items still need a human with production access.
verification, in particular the `status` check constraint the rollback keys
on?
## Project visibility: the application and the database disagree
**This is the most consequential open item, and it is a decision, not a bug.**
The brief that prompted tenant separation is explicit: two people in one
organization work on different books and **both books are visible to both of
them**. The control plane's own policy helper says the opposite.
`app.can_access_project(project_id)` returns true only for a platform
administrator, the organization's administrator, or someone holding a
`project`-scoped role **on that specific project**:
```sql
OR EXISTS (
SELECT 1 FROM user_roles ur
WHERE ur.user_id = app.current_user_id()
AND ur.project_id = target_project_id
AND ur.scope = 'project'
AND ur.status = 'active'
)
```
The application implements the brief, not the helper. Within an organization
every project is visible to every account, and a `project`-scoped assignment says
who is *working* on a project rather than who may *know* it exists. The rule
lives in `umbratrans_web/tenancy.py` and is held by `test_tenancy.py`,
`test_api_tenancy.py`, and `test_the_organization_wide_listing_reaches_a_members_projects`.
The two rules agree on the question that matters most — no caller sees across an
organization boundary — and differ only on project-level visibility.
**Why the application wins today:** row-level security is *not enforced*, because
the application owns its tables and no policy is forced. The adapter's own
`WHERE` clauses are therefore the only control, and they implement the brief.
**What happens if RLS is switched on:** `projects_select` becomes load-bearing
and `app.can_access_project` will restrict projects to assigned members. Under
the brief that is a **behaviour change, not a security improvement**: a member
assigned to one book would stop seeing their colleague's. The policy must be
amended in the same change that forces RLS, to something like
```sql
USING (app.is_platform_admin() OR app.is_organization_member(organization_id))
```
Doing both in one reviewed migration is the requirement. Forcing RLS without
amending the policy would ship a silent, tenant-internal regression that no test
in this repository would catch, because the tests exercise the adapter and the
adapter does not ask the database for permission.
**No migration was needed for any of this.** The `projects` table, its
`(id, organization_id)` unique constraint, the `on_hold`/`released`/`archived`
statuses, and the `project`-scoped arm of `user_roles` were all already in the
schema and unused. The application now uses them; it does not alter them.
### One consequence worth naming
An assignment writes a `project`-scoped `user_roles` row carrying the
`viewer` role, because there is **no `member` role at project scope** - the
seeded project roles are `translator`, `proofreader`, `project_manager`,
`project_owner`, `release_manager`, `audio_director`, and `viewer`, and all of
them name a job rather than participation. `viewer` is the least privileged and
the only one claiming nothing about what the assignee does there.
This is provisional because the project workflow is not defined yet. When it is,
`PostgresAccountRepository.PROJECT_ASSIGNMENT_ROLE` is where the role changes.
It is a constant rather than a query literal so that the change does not require
a schema migration.
### An assignment is a second `user_roles` row, and that broke sign-in
An assignment writes a **second** active `user_roles` row for the account,
alongside the organization-scoped one: a *tenant* row saying which organization
the account belongs to, and a *project* row saying who is working on what. A
project row also carries an organization, so nothing but `scope` tells them
apart.
**This shipped as a lockout and was caught only by running the integration
suite.** `_resolve_assignment` refused an account holding more than one active
row. So the moment anybody was assigned to a project they held two rows, became
unresolvable, and could not authenticate — locked out of the very page the
assignment was meant to show them. `_sync_assignment` had the same defect, so a
role change on an assigned member failed too.
Every query that asks "which organization is this account in", or "what role
does it hold there", now applies `TENANT_ROW_PREDICATE` (`ur.scope <> 'project'`)
in `postgres_repository.py`. Two exemptions, both narrow and both reasoned in
place: a row a statement *creates* (the scope was just computed and is in the
column list), and the one write keyed on the row's own primary key in
`_sync_assignment`, which names exactly one row already chosen by a preceding
`SELECT`.
The guard itself survives, narrowed to the ambiguity that actually matters. Two
**tenant** rows is still refused, because the organization returned scopes every
administrative decision and resolving to whichever sorted first would hand out
the wrong tenant's authority.
`test_two_tenants_for_one_account_is_still_refused` holds that down, writing the
impossible state directly because no service path produces it.
Two tests hold this, and each catches the regression on its own:
- `test_an_assigned_member_can_still_sign_in` — behavioural, and deliberately
asserts the *sign-in* path rather than the resolver, because the resolver
being correct is not the same as the account being usable.
- `test_every_user_roles_query_distinguishes_tenancy_from_project_membership` —
structural, over the adapter's SQL. Needed because three of the six
scope-blind queries were harmless *only* because the assignment role is named
`viewer` and never matched a tenant role name. That is luck, and a behavioural
test cannot tell luck from a rule.
#### The review gate's own query must be amended
The existing gate asks:
```sql
SELECT user_id, count(*) FROM public.user_roles
WHERE status = 'active' GROUP BY 1 HAVING count(*) > 1;
```
**As written it is now wrong.** It fires on every assigned member, so its
meaning silently changes from "this account has two tenants" to "this account is
working on something" — which is not a tenancy error. It must be narrowed:
```sql
SELECT user_id, count(*) FROM public.user_roles
WHERE status = 'active' AND project_id IS NULL
GROUP BY 1 HAVING count(*) > 1;
```
Verified against a real database, in a rolled-back transaction, with an
organization, a project, a member, and both assignment rows in place. Without the
predicate the query flagged 1 row; with it, 0. The gate above is the one place
in this document that a reviewer must edit before approving.
## Gates that must pass before approval
- Logical backup taken, checksummed, and restore-tested on a scratch database.

View File

@@ -274,6 +274,39 @@ def normalize_organization_name(value: str) -> str:
return text
MIN_PROJECT_NAME_LENGTH = 2
MAX_PROJECT_NAME_LENGTH = 120
#: A project slug is a URL handle, so it carries the same limits as an
#: organization's. It is unique within an organization, not globally, so two
#: tenants may both have a project called "book-one".
MAX_PROJECT_SLUG_LENGTH = 63
def normalize_project_name(value: str) -> str:
"""Return the canonical stored form of a project name."""
text = " ".join(value.split())
if not MIN_PROJECT_NAME_LENGTH <= len(text) <= MAX_PROJECT_NAME_LENGTH or _CONTROL_CHARS_RE.search(text):
raise InputError(
"invalid_project_name",
f"Project name must be {MIN_PROJECT_NAME_LENGTH}-{MAX_PROJECT_NAME_LENGTH} characters.",
)
return text
def normalize_project_slug(value: str) -> str:
"""Return the canonical stored form of a project slug."""
text = _SLUG_SEPARATOR_RE.sub("-", value.strip().lower()).strip("-")
if not text or len(text) > MAX_PROJECT_SLUG_LENGTH or not _SLUG_RE.fullmatch(text):
raise InputError(
"invalid_project_slug",
"Project slug must be lowercase letters, digits, and single hyphens.",
)
return text
def normalize_organization_slug(value: str) -> str:
"""Return the canonical stored form of an organization slug.
@@ -513,6 +546,92 @@ class AccountView:
return self.status is not AccountStatus.INVITED
class ProjectStatus(StrEnum):
"""Lifecycle of a project, matching the control plane's own check constraint.
Read from the schema rather than invented, so a status the database would
refuse is never offered:
CHECK (status = ANY (ARRAY['active', 'on_hold', 'released', 'archived']))
``archived`` is terminal. ``released`` means the work has shipped; it is not
the same as archived, and it is not terminal either - a released project can
still be read.
"""
ACTIVE = "active"
ON_HOLD = "on_hold"
RELEASED = "released"
ARCHIVED = "archived"
@property
def is_terminal(self) -> bool:
return self is ProjectStatus.ARCHIVED
#: The transitions an administrator may make. Archived is terminal, and a
#: released project may be archived or returned to active; an archived one may do
#: nothing at all.
PROJECT_STATUS_TRANSITIONS: Mapping[ProjectStatus, tuple[ProjectStatus, ...]] = {
ProjectStatus.ACTIVE: (ProjectStatus.ON_HOLD, ProjectStatus.RELEASED, ProjectStatus.ARCHIVED),
ProjectStatus.ON_HOLD: (ProjectStatus.ACTIVE, ProjectStatus.RELEASED, ProjectStatus.ARCHIVED),
ProjectStatus.RELEASED: (ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED),
ProjectStatus.ARCHIVED: (),
}
@dataclass(frozen=True, slots=True)
class Project:
"""One unit of work, belonging to exactly one organization.
The table is ``projects`` in the control plane and was already there, unused
by this application, with ``(id, organization_id)`` unique so a project
identifier cannot be ambiguous across tenants.
"""
id: UUID
organization_id: UUID
name: str
slug: str
status: ProjectStatus
created_at: datetime
created_by: UUID | None = None
updated_at: datetime | None = None
version: int = 1
@property
def slug_key(self) -> str:
return str(self.slug).casefold()
@dataclass(frozen=True, slots=True)
class ProjectView:
"""The only project representation that may cross the API boundary.
``assigned_account_ids`` is who is *working* on the project, which is not the
same as who can see it. Within an organization every project is visible to
every account; see :mod:`umbratrans_web.tenancy` for why those are separate
questions.
"""
id: UUID
organization_id: UUID
name: str
slug: str
status: ProjectStatus
created_at: datetime
created_by: UUID | None = None
updated_at: datetime | None = None
assigned_account_ids: tuple[UUID, ...] = ()
assigned_count: int = 0
@property
def is_visible_to_all_in_organization(self) -> bool:
"""Always true, and stated so the rule is visible where projects are used."""
return True
@dataclass(frozen=True, slots=True)
class Organization:
"""A tenant that owns projects and, through it, accounts.
@@ -648,6 +767,11 @@ __all__ = [
"EnrollmentMode",
"IssuedSession",
"Organization",
"Project",
"normalize_project_name",
"normalize_project_slug",
"ProjectStatus",
"ProjectView",
"OrganizationStatus",
"OrganizationView",
"Principal",

View File

@@ -61,6 +61,8 @@ from .models import (
CredentialType,
Organization,
OrganizationStatus,
Project,
ProjectStatus,
RoleName,
SessionRecord,
User,
@@ -344,6 +346,7 @@ class PostgresAccountRepository(AccountRepository):
JOIN public.roles r ON r.id = ur.role_id
WHERE r.name = %s
AND ur.status = 'active'
AND {TENANT_ROW_PREDICATE}
""",
(str(role),),
).fetchone()
@@ -370,7 +373,8 @@ class PostgresAccountRepository(AccountRepository):
params: list[Any] = []
if organization_id is not None:
conditions.append(
"EXISTS (SELECT 1 FROM public.user_roles ur WHERE ur.user_id = u.id AND ur.organization_id = %s AND ur.status = 'active')"
f"EXISTS (SELECT 1 FROM public.user_roles ur WHERE ur.user_id = u.id "
f"AND ur.organization_id = %s AND ur.status = 'active' AND {TENANT_ROW_PREDICATE})"
)
params.append(organization_id)
if status is not None:
@@ -378,7 +382,8 @@ class PostgresAccountRepository(AccountRepository):
params.append(status.value)
if role is not None:
conditions.append(
"EXISTS (SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id = ur.role_id WHERE ur.user_id = u.id AND r.name = %s AND ur.status = 'active')"
f"EXISTS (SELECT 1 FROM public.user_roles ur JOIN public.roles r ON r.id = ur.role_id "
f"WHERE ur.user_id = u.id AND r.name = %s AND ur.status = 'active' AND {TENANT_ROW_PREDICATE})"
)
params.append(role)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
@@ -421,10 +426,231 @@ class PostgresAccountRepository(AccountRepository):
return "", []
return (
"WHERE EXISTS (SELECT 1 FROM public.user_roles ur "
"WHERE ur.user_id = u.id AND ur.organization_id = %s AND ur.status = 'active')",
f"WHERE ur.user_id = u.id AND ur.organization_id = %s "
f"AND ur.status = 'active' AND {TENANT_ROW_PREDICATE})",
[organization_id],
)
# -- projects ------------------------------------------------------------
def create_project(self, project: Project) -> Project:
with self._connection() as connection:
try:
connection.execute(
"""
INSERT INTO public.projects (
id, organization_id, name, slug, status, created_by, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(
project.id,
project.organization_id,
project.name,
project.slug,
str(project.status),
project.created_by,
project.created_at,
project.updated_at or project.created_at,
),
)
except psycopg.errors.UniqueViolation as error:
raise _unique_conflict(error, project.slug) from None
return project
def get_project(self, project_id: UUID, *, organization_id: UUID | None = None) -> Project | None:
"""Fetch a project, matching the organization in the query.
The organization is part of the predicate rather than a check afterwards.
A project identifier is unique on its own, so filtering afterwards would
fetch a row and then decide it was not the caller's - which is a row read
that should not have happened at all.
"""
conditions, params = ["p.id = %s"], [project_id]
if organization_id is not None:
conditions.append("p.organization_id = %s")
params.append(organization_id)
with self._connection() as connection:
row = connection.execute(
f"SELECT {_PROJECT_COLUMNS} FROM public.projects p WHERE {' AND '.join(conditions)}",
params,
).fetchone()
return _project_from_row(row) if row else None
def get_project_by_slug(self, slug: str, *, organization_id: UUID) -> Project | None:
"""Fetch by handle, case-insensitively.
The comparison is left to the ``citext`` column rather than casting it to
``text``. ``slug::text = %s`` is an ordinary text comparison, so it is
case-*sensitive*, and ``/Book-One`` would fail to find ``book-one`` while
appearing to work for every other store in the system. The cast looks like
a type accommodation for the parameter and is in fact a silent change of
behaviour.
"""
with self._connection() as connection:
row = connection.execute(
f"SELECT {_PROJECT_COLUMNS} FROM public.projects p "
"WHERE p.organization_id = %s AND p.slug = %s",
(organization_id, str(slug)),
).fetchone()
return _project_from_row(row) if row else None
def save_project(self, project: Project) -> Project:
with self._connection() as connection:
row = connection.execute(
"""
UPDATE public.projects
SET name = %s, slug = %s, status = %s, updated_at = %s
WHERE id = %s AND organization_id = %s
RETURNING id
""",
(
project.name,
project.slug,
str(project.status),
project.updated_at or utc_now(),
project.id,
project.organization_id,
),
).fetchone()
if row is None:
raise NotFoundError("project_not_found")
return project
def find_projects(
self,
*,
organization_id: UUID | None = None,
statuses: Sequence[ProjectStatus] | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Project], int]:
conditions: list[str] = []
params: list[Any] = []
if organization_id is not None:
conditions.append("p.organization_id = %s")
params.append(organization_id)
if statuses:
# Every status the model knows is rendered, so a value outside the
# database's own check constraint is refused by the store rather than
# silently matching nothing.
wanted = [str(status) for status in ProjectStatus]
for status in statuses:
if str(status) not in wanted:
raise RepositoryError("unmodelled_project_status", f"Unrecognised project status {status!r}.")
conditions.append("p.status = ANY(%s)")
params.append([str(status) for status in statuses])
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
with self._connection() as connection:
total_row = connection.execute(
f"SELECT count(*) AS total FROM public.projects p {where}", params
).fetchone()
total = int(total_row["total"]) if total_row else 0
if total == 0:
return [], 0
rows = connection.execute(
f"SELECT {_PROJECT_COLUMNS} FROM public.projects p {where} "
"ORDER BY p.created_at DESC, p.id LIMIT %s OFFSET %s",
[*params, max(limit, 0), max(offset, 0)],
).fetchall()
return [_project_from_row(row) for row in rows], total
def count_projects(self, *, organization_id: UUID | None = None) -> int:
if organization_id is None:
sql, params = "SELECT count(*) AS total FROM public.projects", []
else:
sql, params = "SELECT count(*) AS total FROM public.projects WHERE organization_id = %s", [organization_id]
with self._connection() as connection:
row = connection.execute(sql, params).fetchone()
return int(row["total"]) if row else 0
def list_project_member_ids(self, project_id: UUID) -> list[UUID]:
"""Project-scoped assignments, from the same table the roles live in.
Scoped to ``scope = 'project'`` because a user's organization-scoped
assignment must not be read as membership of every project in the tenant.
That is the mistake that would make one assignment grant project work.
"""
with self._connection() as connection:
rows = connection.execute(
"SELECT user_id FROM public.user_roles "
"WHERE project_id = %s AND scope = 'project' AND status = 'active' "
"ORDER BY created_at, user_id",
(project_id,),
).fetchall()
return [row["user_id"] for row in rows]
#: The project-scoped role an assignment grants.
#:
#: There is no ``member`` role at project scope. The control plane seeds
#: ``translator``, ``proofreader``, ``project_manager``, ``project_owner``,
#: ``release_manager``, ``audio_director``, and ``viewer`` - all scoped to
#: ``project`` - and those name jobs rather than participation. ``viewer`` is
#: used because it is the least privileged of them and is the only one that
#: claims nothing about what the assignee is doing there.
#:
#: This is provisional and deliberately so. An assignment currently means "is
#: working on this", and the workflow that would give it a job title is not
#: defined yet. When it is, this constant is where the role changes, and the
#: alternative to changing it is a migration - so it is named here rather than
#: buried in a query.
PROJECT_ASSIGNMENT_ROLE = "viewer"
def assign_user_to_project(self, project_id: UUID, user_id: UUID) -> bool:
"""Add a project-scoped assignment, or report that there already is one.
Idempotent rather than erroring, because assigning the same account twice
is a no-op an administrator can reasonably make. The insert is
conditional so two concurrent requests cannot both add one.
"""
with self._connection() as connection:
row = connection.execute(
"""
INSERT INTO public.user_roles (user_id, role_id, scope, organization_id, project_id, status)
SELECT %s, r.id, 'project', p.organization_id, p.id, 'active'
FROM public.projects p
JOIN public.roles r ON r.name = %s AND r.scope = 'project'
WHERE p.id = %s
ON CONFLICT DO NOTHING
RETURNING id
""",
(user_id, self.PROJECT_ASSIGNMENT_ROLE, project_id),
).fetchone()
if row is not None:
return True
# Nothing inserted: either it was already there, or the project has no
# member-scoped project role seeded, or the project does not exist.
existing = connection.execute(
"SELECT 1 AS present FROM public.user_roles "
"WHERE user_id = %s AND project_id = %s AND scope = 'project' AND status = 'active'",
(user_id, project_id),
).fetchone()
if existing is not None:
return False
project_row = connection.execute(
"SELECT 1 AS present FROM public.projects WHERE id = %s", (project_id,)
).fetchone()
if project_row is None:
raise NotFoundError("project_not_found")
raise RepositoryError(
"project_role_not_seeded",
f"No project-scoped {self.PROJECT_ASSIGNMENT_ROLE!r} role is present in the roles table.",
)
def unassign_user_from_project(self, project_id: UUID, user_id: UUID) -> bool:
with self._connection() as connection:
rows = connection.execute(
"DELETE FROM public.user_roles "
"WHERE user_id = %s AND project_id = %s AND scope = 'project' AND status = 'active' "
"RETURNING id",
(user_id, project_id),
).fetchall()
return bool(rows)
# -- role assignments ---------------------------------------------------
def _insert_assignment(self, connection: psycopg.Connection[Any], *, user: User, now: datetime) -> None:
@@ -456,12 +682,20 @@ class PostgresAccountRepository(AccountRepository):
)
def _sync_assignment(self, connection: psycopg.Connection[Any], *, user: User) -> None:
"""Make the active assignment match the requested role and organization.
"""Make the account's *tenant* assignment match the requested role and organization.
Phase 1 models exactly one role and one organization per account, so
the target state is fully determined. A user holding several active
assignments is refused by :func:`_resolve_assignment` on read; this
write path refuses to collapse them silently either.
One role and one organization per account, so the target state is fully
determined.
Only tenant rows are read and written. An account assigned to a project
holds a second active ``user_roles`` row that says who is working on what;
it is not a second tenancy, and it must survive this call untouched.
Counting it would make every role change on an assigned member fail, and
a narrow ``UPDATE`` that reached it would strip the assignment while
appearing to change a role.
Several *tenant* rows are still refused, on the same grounds as
:func:`_resolve_assignment`: this path will not collapse them silently.
"""
scope, organization_id = _assignment_shape(user)
@@ -476,7 +710,8 @@ class PostgresAccountRepository(AccountRepository):
"The requested role does not have the scope required for this assignment.",
)
existing = connection.execute(
"SELECT id, role_id, organization_id FROM public.user_roles WHERE user_id = %s AND status = 'active'",
"SELECT id, role_id, organization_id FROM public.user_roles ur "
f"WHERE ur.user_id = %s AND ur.status = 'active' AND {TENANT_ROW_PREDICATE}",
(user.id,),
).fetchall()
if len(existing) > 1:
@@ -1052,12 +1287,29 @@ def _assignment_shape(user: User) -> tuple[str, UUID | None]:
def _resolve_assignment(connection: psycopg.Connection[Any], user_id: UUID) -> tuple[RoleName, UUID | None]:
"""Resolve one role and organization for a user, or refuse.
Phase 1 can represent exactly one active assignment. A user with more than
one active row is refused rather than resolved to an arbitrary row: the
organization on the returned assignment becomes the scope of every
administrative decision, so guessing would hand out the wrong tenant's
authority. Reconciling this with project-scoped membership is the open
review question recorded in ``migrations/README.md``.
An account may hold several active ``user_roles`` rows, because two different
kinds of row live in that table:
* a **tenant** row, at ``platform`` or ``organization`` scope, saying which
organization the account belongs to and what it may do there;
* a **project** row, at ``project`` scope, saying who is working on which
project.
Only the first kind answers "which tenant is this?", so only the first kind is
read here. Project rows are excluded by scope rather than by organisation,
because a project row carries an organization too and filtering on the
column rather than the scope would be indistinguishable from the tenant row
it sits beside.
Counting all rows here would refuse to resolve any assigned member, which
locks them out of authentication entirely - the failure mode is a member who
worked on a book and then could not sign in.
One tenant row remains the rule, and one tenant row is still ambiguous: the
organization returned scopes every administrative decision, so a second tenant
row is refused rather than resolved to whichever sorted first, which would
hand out the wrong organization's authority. Project membership does not
create that ambiguity and is not a reason to refuse.
"""
rows = connection.execute(
@@ -1065,7 +1317,9 @@ def _resolve_assignment(connection: psycopg.Connection[Any], user_id: UUID) -> t
SELECT r.name, ur.organization_id, ur.scope
FROM public.user_roles ur
JOIN public.roles r ON r.id = ur.role_id
WHERE ur.user_id = %s AND ur.status = 'active'
WHERE ur.user_id = %s
AND ur.status = 'active'
AND ur.scope <> 'project'
ORDER BY ur.scope, ur.created_at, ur.id
""",
(user_id,),
@@ -1129,6 +1383,59 @@ def _user_from_row(
)
#: The predicate that selects an account's *tenancy* rows and excludes project
#: rows.
#:
#: ``user_roles`` holds two different statements. A tenant row (scope ``platform``
#: or ``organization``) says which organization the account belongs to and what it
#: may do there. A project row (scope ``project``) says who is working on which
#: project. Project rows also carry an organization, so they cannot be told apart
#: by that column - only by scope.
#:
#: Every query that asks "which organization is this account in", or "what role
#: does it hold there", must apply this. Leaving one out is not a style
#: difference: it made role resolution refuse any assigned member, locking them
#: out of sign-in, because such an account now holds two active rows.
#:
#: Written for a table aliased ``ur``.
TENANT_ROW_PREDICATE = "ur.scope <> 'project'"
_PROJECT_COLUMNS = (
"p.id, p.organization_id, p.name, p.slug, p.status, p.created_by, p.created_at, p.updated_at"
)
def _project_from_row(row: Mapping[str, Any]) -> Project:
"""Map a ``projects`` row.
The status is mapped through the same table of modelled values the accounts
use, and an unrecognised value is refused rather than coerced. The database's
check constraint allows exactly the four the model declares, so a row outside
them would mean the schema and the application disagree, and guessing which is
right is how a project ends up quietly archived.
"""
return Project(
id=row["id"],
organization_id=row["organization_id"],
name=row["name"],
slug=str(row["slug"]),
status=_project_status(row["status"]),
created_by=row["created_by"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
def _project_status(value: str) -> ProjectStatus:
try:
return ProjectStatus(value)
except ValueError:
raise RepositoryError(
"unmodelled_project_status", f"The store holds a project status this version does not know: {value!r}."
) from None
def _organization_from_row(row: Mapping[str, Any]) -> Organization:
return Organization(
id=row["id"],

View File

@@ -14,7 +14,7 @@ from __future__ import annotations
import threading
from dataclasses import replace
from datetime import datetime
from typing import Protocol, runtime_checkable
from typing import Protocol, Sequence, runtime_checkable
from uuid import UUID
from .errors import ConflictError, NotFoundError, RepositoryError
@@ -25,6 +25,8 @@ from .models import (
CredentialRecord,
Organization,
OrganizationStatus,
Project,
ProjectStatus,
RoleName,
SessionRecord,
User,
@@ -72,6 +74,77 @@ class AccountRepository(Protocol):
raise NotImplementedError(f"{type(self).__name__} does not implement count_users().")
# -- projects ------------------------------------------------------------
def create_project(self, project: Project) -> Project:
"""Insert a project. The organization is part of the row, not a filter."""
raise NotImplementedError(f"{type(self).__name__} does not implement create_project().")
def get_project(self, project_id: UUID, *, organization_id: UUID | None = None) -> Project | None:
"""Fetch one project.
``organization_id`` is part of the lookup, not a check afterwards. A
project identifier is unique on its own, so a lookup that ignored the
organization would hand back a project belonging to another tenant, which
is the one mistake a tenant boundary must not make.
"""
raise NotImplementedError(f"{type(self).__name__} does not implement get_project().")
def get_project_by_slug(self, slug: str, *, organization_id: UUID) -> Project | None:
"""Fetch a project by slug, within one organization.
The organization is required rather than optional because a slug is only
unique within an organization, so an unscoped lookup would be ambiguous by
design.
"""
raise NotImplementedError(f"{type(self).__name__} does not implement get_project_by_slug().")
def save_project(self, project: Project) -> Project:
raise NotImplementedError(f"{type(self).__name__} does not implement save_project().")
def find_projects(
self,
*,
organization_id: UUID | None = None,
statuses: Sequence[ProjectStatus] | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Project], int]:
"""List projects.
``organization_id`` of ``None`` means every organization, and is only
reachable from a platform administrator: everyone else is pinned to a
concrete identifier by :func:`umbratrans_web.tenancy.scope_for`.
"""
raise NotImplementedError(f"{type(self).__name__} does not implement find_projects().")
def count_projects(self, *, organization_id: UUID | None = None) -> int:
raise NotImplementedError(f"{type(self).__name__} does not implement count_projects().")
def list_project_member_ids(self, project_id: UUID) -> list[UUID]:
"""Accounts assigned to a project, from active project-scoped assignments.
This is who is *working* on a project, not who may see it. Within an
organization every project is visible to every account; see
:mod:`umbratrans_web.tenancy`.
"""
raise NotImplementedError(f"{type(self).__name__} does not implement list_project_member_ids().")
def assign_user_to_project(self, project_id: UUID, user_id: UUID) -> bool:
"""Assign an account to a project. Returns False if already assigned."""
raise NotImplementedError(f"{type(self).__name__} does not implement assign_user_to_project().")
def unassign_user_from_project(self, project_id: UUID, user_id: UUID) -> bool:
"""Remove an assignment. Returns False if there was not one."""
raise NotImplementedError(f"{type(self).__name__} does not implement unassign_user_from_project().")
def count_users_with_role(self, role: RoleName) -> int:
"""Count accounts holding a role, with an active assignment.
@@ -234,6 +307,12 @@ class InMemoryAccountRepository:
self._sessions_by_token: dict[str, UUID] = {}
self._verification_tokens: dict[UUID, VerificationToken] = {}
self._audit: list[AuditEvent] = []
self._projects: dict[UUID, Project] = {}
# Project membership is a second assignment for an account, alongside the
# one the domain User carries. The control plane holds both as rows in
# user_roles; here it is a plain set of identifiers, which is the same fact
# with less ceremony.
self._project_members: dict[UUID, set[UUID]] = {}
# -- users ---------------------------------------------------------------
@@ -286,6 +365,88 @@ class InMemoryAccountRepository:
with self._lock:
return sum(1 for user in self._users.values() if user.role is role and user.status is AccountStatus.ACTIVE)
# -- projects ------------------------------------------------------------
def create_project(self, project: Project) -> Project:
with self._lock:
if project.id in self._projects:
raise ConflictError("project_exists", "A project with that identifier already exists.")
if any(
item.organization_id == project.organization_id and item.slug_key == project.slug_key
for item in self._projects.values()
):
raise ConflictError("project_slug_taken", "That project slug is already in use in this organization.")
self._projects[project.id] = project
return project
def get_project(self, project_id: UUID, *, organization_id: UUID | None = None) -> Project | None:
with self._lock:
project = self._projects.get(project_id)
if project is None:
return None
if organization_id is not None and project.organization_id != organization_id:
return None
return project
def get_project_by_slug(self, slug: str, *, organization_id: UUID) -> Project | None:
key = str(slug).casefold()
with self._lock:
for project in self._projects.values():
if project.organization_id == organization_id and project.slug_key == key:
return project
return None
def save_project(self, project: Project) -> Project:
with self._lock:
if project.id not in self._projects:
raise NotFoundError("project_not_found")
self._projects[project.id] = project
return project
def find_projects(
self,
*,
organization_id: UUID | None = None,
statuses: Sequence[ProjectStatus] | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Project], int]:
with self._lock:
selected = [
project
for project in self._projects.values()
if (organization_id is None or project.organization_id == organization_id)
and (statuses is None or project.status in statuses)
]
selected.sort(key=lambda project: (project.created_at, str(project.id)), reverse=True)
return selected[max(offset, 0) : max(offset, 0) + max(limit, 0)], len(selected)
def count_projects(self, *, organization_id: UUID | None = None) -> int:
with self._lock:
if organization_id is None:
return len(self._projects)
return sum(1 for project in self._projects.values() if project.organization_id == organization_id)
def list_project_member_ids(self, project_id: UUID) -> list[UUID]:
with self._lock:
return sorted(self._project_members.get(project_id, set()))
def assign_user_to_project(self, project_id: UUID, user_id: UUID) -> bool:
with self._lock:
members = self._project_members.setdefault(project_id, set())
if user_id in members:
return False
members.add(user_id)
return True
def unassign_user_from_project(self, project_id: UUID, user_id: UUID) -> bool:
with self._lock:
members = self._project_members.get(project_id)
if not members or user_id not in members:
return False
members.discard(user_id)
return True
# -- organizations -------------------------------------------------------
def create_organization(self, organization: Organization) -> Organization:

View File

@@ -21,7 +21,7 @@ from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from .app import get_service, request_id, require_admin
from .models import AccountStatus, EnrollmentMode, OrganizationStatus, Principal, RoleName
from .models import AccountStatus, EnrollmentMode, OrganizationStatus, Principal, ProjectStatus, RoleName
from .schemas import (
AccountListResponse,
AccountResponse,
@@ -30,14 +30,19 @@ from .schemas import (
CreateAccountRequest,
CreateAccountResponse,
CreateOrganizationRequest,
CreateProjectRequest,
OrganizationListResponse,
OrganizationResponse,
OrganizationStatusChangeResponse,
OrganizationSummaryResponse,
PasswordResetRequestedResponse,
ProjectAssignmentResponse,
ProjectListResponse,
ProjectResponse,
RevokeSessionsResponse,
SetAccountStatusRequest,
SetOrganizationStatusRequest,
SetProjectStatusRequest,
StatusChangeResponse,
UpdateOrganizationRequest,
)
@@ -296,6 +301,131 @@ def set_organization_status(
)
# -- projects ----------------------------------------------------------------
#
# Creating, populating, and moving a project through its lifecycle are
# administrator acts. *Reading* them is not, and is therefore not here: a member
# sees their organization's projects through GET /api/workspace, which is scoped
# the same way but requires no administrator role. Keeping the read on the
# self-service path is what stops a member's dashboard from needing a second set of
# rules.
@router.get("/projects", response_model=ProjectListResponse)
def list_projects(
request: Request,
principal: Principal = Depends(require_admin),
status: list[ProjectStatus] | None = Query(default=None),
organization_id: UUID | None = Query(default=None),
limit: int = Query(default=MAX_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE),
offset: int = Query(default=0, ge=0),
) -> ProjectListResponse:
"""List projects in the caller's organization.
Organization-wide, not assignment-filtered: an administrator setting up work
needs to see the projects nobody is on, or they would never be given to
anyone. See :mod:`umbratrans_web.tenancy`.
"""
service: AccountService = get_service(request)
projects, total = service.list_projects(
principal, organization_id=organization_id, statuses=status, limit=limit, offset=offset
)
return ProjectListResponse(
projects=[ProjectResponse.from_view(project) for project in projects], total=total
)
@router.post("/projects", response_model=ProjectResponse, status_code=201)
def create_project(
payload: CreateProjectRequest,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectResponse:
"""Create a project. Pinned to the caller's organization; see the service."""
service: AccountService = get_service(request)
project = service.create_project(
principal,
name=payload.name,
slug=payload.slug,
organization_id=payload.organization_id,
request_id=request_id(request),
)
return ProjectResponse.from_view(project)
@router.get("/projects/{project_id}", response_model=ProjectResponse)
def get_project(
project_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectResponse:
service: AccountService = get_service(request)
return ProjectResponse.from_view(service.get_project(principal, project_id))
@router.patch("/projects/{project_id}/status", response_model=ProjectResponse)
def set_project_status(
project_id: UUID,
payload: SetProjectStatusRequest,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectResponse:
"""Move a project through its lifecycle. Archived is terminal."""
service: AccountService = get_service(request)
project = service.set_project_status(
principal, project_id, payload.status, reason=payload.reason, request_id=request_id(request)
)
return ProjectResponse.from_view(project)
@router.put("/projects/{project_id}/members/{user_id}", response_model=ProjectAssignmentResponse)
def assign_to_project(
project_id: UUID,
user_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectAssignmentResponse:
"""Say an account is working on a project.
A ``PUT`` because the desired state is "this account is assigned", which is
true whether or not it already was. Repeating it is not an error, so a client
retrying after a dropped response gets the answer it wanted.
"""
service: AccountService = get_service(request)
before = service.get_project(principal, project_id)
project = service.assign_account_to_project(principal, project_id, user_id, request_id=request_id(request))
return ProjectAssignmentResponse(
project=ProjectResponse.from_view(project),
changed=before.assigned_account_ids != project.assigned_account_ids,
)
@router.delete("/projects/{project_id}/members/{user_id}", response_model=ProjectAssignmentResponse)
def unassign_from_project(
project_id: UUID,
user_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectAssignmentResponse:
"""Remove an assignment.
The project stays visible to everyone in the organization; only the work stops
being anybody's. See :mod:`umbratrans_web.tenancy`.
"""
service: AccountService = get_service(request)
before = service.get_project(principal, project_id)
project = service.unassign_account_from_project(principal, project_id, user_id, request_id=request_id(request))
return ProjectAssignmentResponse(
project=ProjectResponse.from_view(project),
changed=before.assigned_account_ids != project.assigned_account_ids,
)
@router.get("/audit", response_model=AuditListResponse)
def list_audit_events(
request: Request,

View File

@@ -0,0 +1,73 @@
"""``/api/workspace``: the signed-in caller's organization and its work.
Separate from ``/api/auth`` and ``/api/uadmin`` on purpose. Authentication answers
"who are you"; administration answers "what may you change". Neither answers
"what does your organization look like today", which is what every signed-in
person opens the application to find, and which must work for a plain member with
no administrative role at all.
One endpoint, scoped server-side, returning the organization and its projects
together. A dashboard assembled in the browser from several calls is a dashboard
that can show two tenants at once if a filter is mistimed or a response is cached
from a previous sign-in - and the pieces would disagree while nothing noticed.
Here the caller cannot name an organization, so there is no filter to mistime:
``tenancy.scope_for`` resolves it from the session and this route never accepts
one.
Membership carries no privilege, so this route requires a session and nothing
more. It deliberately does not sit behind ``require_admin``: a member's dashboard
is not a privileged view, and making it privileged would mean either hiding the
organization from its own members or giving every member administrative rights to
see it.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from .app import get_service, require_principal
from .models import Principal
from .schemas import (
OrganizationResponse,
ProjectResponse,
UserResponse,
WorkspaceProjectResponse,
WorkspaceResponse,
)
from .service import AccountService
router = APIRouter()
@router.get("", response_model=WorkspaceResponse)
def workspace(
request: Request,
principal: Principal = Depends(require_principal),
) -> WorkspaceResponse:
"""The caller's organization, its projects, and the projects they are on.
``organization`` is ``null`` for a platform administrator, who belongs to no
tenant. That is a normal answer, not a failure: they see the platform, and
choosing which organization's work to open is an explicit act rather than a
default that could quietly become somebody else's.
"""
service: AccountService = get_service(request)
snapshot = service.workspace(principal)
def render(views) -> list[WorkspaceProjectResponse]:
return [
WorkspaceProjectResponse(
**ProjectResponse.from_view(view).model_dump(),
assigned_to_me=principal.user_id in view.assigned_account_ids,
)
for view in views
]
return WorkspaceResponse(
user=UserResponse.from_view(service.self_view(principal.user_id)),
organization=OrganizationResponse.from_view(snapshot.organization) if snapshot.organization else None,
projects=render(snapshot.projects),
total=snapshot.total,
assigned_to_me=render(snapshot.assigned_to_me),
)

View File

@@ -26,6 +26,8 @@ from .models import (
EnrollmentMode,
OrganizationStatus,
OrganizationView,
ProjectStatus,
ProjectView,
RoleName,
)
@@ -320,6 +322,120 @@ class StatusChangeResponse(BaseModel):
revoked_sessions: int
class ProjectResponse(BaseModel):
"""A project as returned by the API.
``assigned_account_ids`` is who is *working* on the project. It is not a
visibility filter: within an organization every project is visible to every
account, so this says who has it rather than who may look. See
:mod:`umbratrans_web.tenancy`.
"""
id: UUID
organization_id: UUID
name: str
slug: str
status: ProjectStatus
assigned_account_ids: list[UUID] = Field(default_factory=list)
assigned_count: int = 0
created_at: datetime | None = None
created_by: UUID | None = None
updated_at: datetime | None = None
@classmethod
def from_view(cls, view: ProjectView) -> ProjectResponse:
return cls(
id=view.id,
organization_id=view.organization_id,
name=view.name,
slug=view.slug,
status=view.status,
assigned_account_ids=list(view.assigned_account_ids),
assigned_count=view.assigned_count,
created_at=view.created_at,
created_by=view.created_by,
updated_at=view.updated_at,
)
class ProjectListResponse(BaseModel):
"""Projects in the caller's organization, and the count of the whole set.
``total`` counts every matching project, not the page, so a client can say
"showing 20 of 47" without a second request.
"""
projects: list[ProjectResponse]
total: int
class CreateProjectRequest(RequestModel):
"""Create a project inside the caller's organization.
``slug`` is optional and derived from ``name`` when omitted. A supplied slug
is canonicalised rather than required to be already well formed, so an
administrator does not have to learn the rules before naming a project.
"""
name: str = Field(min_length=1, max_length=120)
slug: str | None = Field(default=None, max_length=63)
# Honoured only by a platform administrator. For anyone else the service pins
# the project to their own organization, and refuses a body naming another.
organization_id: UUID | None = None
class SetProjectStatusRequest(RequestModel):
status: ProjectStatus
reason: str | None = Field(default=None, max_length=200)
class ProjectAssignmentResponse(BaseModel):
"""The project as it stands after an assignment change.
The assignment count moves even when the answer is 404-shaped by intent: an
unassign that removed nothing is reported, not treated as a failure, because
asking for a state that is already true is a reasonable thing to do.
"""
project: ProjectResponse
changed: bool
class ProjectMemberRequest(RequestModel):
user_id: UUID
class WorkspaceProjectResponse(ProjectResponse):
"""A project row on the dashboard, with the caller's own involvement.
``assigned_to_me`` is computed per caller, so the same endpoint can show
"your work" and "everything in the organization" without the client
reassembling who is who.
"""
assigned_to_me: bool = False
class WorkspaceResponse(BaseModel):
"""``GET /api/workspace``: the signed-in viewer's organization and its work.
Deliberately one call. A dashboard that fetches the organization, then its
projects, then each project's members is a dashboard that renders three
partial states and can render two organizations at once if a filter is
mistimed. Everything a member may see comes from here, scoped server-side.
"""
user: UserResponse
organization: OrganizationResponse | None
#: Projects in the organization, organization-wide. Empty is a normal state,
#: not an error, and the client renders it as "no projects yet".
projects: list[WorkspaceProjectResponse]
total: int
#: The subset assigned to the caller, for a "your work" view. Always a subset
#: of ``projects``; computed here so the two cannot disagree.
assigned_to_me: list[WorkspaceProjectResponse]
class OrganizationResponse(BaseModel):
"""An organization as returned by the administrator API."""

View File

@@ -35,7 +35,7 @@ import logging
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from types import MappingProxyType
from typing import Callable, Mapping
from typing import Callable, Mapping, Sequence
from uuid import UUID, uuid4
from .config import Settings
@@ -61,6 +61,7 @@ from .models import (
ALLOWED_STATUS_TRANSITIONS,
PASSWORD_RESET_PURPOSE,
PASSWORD_RESET_TOKEN_TTL_SECONDS,
PROJECT_STATUS_TRANSITIONS,
VERIFICATION_PURPOSE_EMAIL,
VERIFICATION_TOKEN_TTL_SECONDS,
AccountStatus,
@@ -76,6 +77,9 @@ from .models import (
OrganizationStatus,
OrganizationView,
Principal,
Project,
ProjectStatus,
ProjectView,
RoleName,
SessionRecord,
User,
@@ -84,6 +88,8 @@ from .models import (
normalize_display_name,
normalize_email,
normalize_organization_name,
normalize_project_name,
normalize_project_slug,
normalize_reason,
normalize_username,
require_canonical_organization_slug,
@@ -99,6 +105,7 @@ from .security import (
sanitize_audit_detail,
utc_now,
)
from .tenancy import TenantScope, resolve_target, scope_for
LOGGER = logging.getLogger("umbratrans_web.service")
@@ -131,6 +138,30 @@ ACTION_AUTH_LOGOUT = "auth.logout"
ACTION_AUTH_LOGOUT_EVERYWHERE = "auth.logout_everywhere"
ACTION_EMAIL_VERIFICATION_ISSUED = "email.verification_issued"
ACTION_EMAIL_VERIFIED = "email.verified"
#: A dashboard shows a tenant's working projects, not a paginated archive. The
#: cap is a bound on one response rather than a page size, so "showing 20 of 47"
#: can never become "showing 20, and no sign there are 27 more".
MAX_WORKSPACE_PROJECTS = 200
@dataclass(frozen=True, slots=True)
class WorkspaceSnapshot:
"""What one caller sees on their dashboard, resolved in one place.
``assigned_to_me`` is derived from ``projects`` rather than fetched
separately, so the two can never disagree about who is working on what.
"""
organization: "OrganizationView | None"
projects: list[ProjectView]
assigned_to_me: list[ProjectView]
total: int
ACTION_PROJECT_CREATED = "project.created"
ACTION_PROJECT_STATUS_CHANGED = "project.status_changed"
ACTION_PROJECT_ASSIGNMENT_ADDED = "project.assignment_added"
ACTION_PROJECT_ASSIGNMENT_REMOVED = "project.assignment_removed"
ACTION_PASSWORD_RESET_REQUESTED = "password.reset_requested"
ACTION_PASSWORD_RESET_COMPLETED = "password.reset_completed"
ACTION_SESSION_REVOKED = "session.revoked"
@@ -1911,8 +1942,273 @@ class AccountService:
temporary_password=password,
)
def _audit(
# -- projects and tenant visibility ---------------------------------------
def visible_scope(self, actor: Principal, requested: UUID | None = None) -> TenantScope:
"""The caller's tenant scope. Every project read and write goes through this.
Exposed on the service so the routes have one call to make rather than
each choosing its own idea of what a caller may see. The rules live in
:mod:`umbratrans_web.tenancy`.
"""
return scope_for(actor, requested)
def list_projects(
self,
actor: Principal,
*,
organization_id: UUID | None = None,
statuses: Sequence[ProjectStatus] | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[ProjectView], int]:
"""Projects the caller may see.
Everyone in an organization sees every project in it. Being assigned to a
project says who is working on it, not who may know it exists, so the
caller's own assignments are not required and are not used as a filter.
A platform administrator sees every organization's projects.
This is a deliberate divergence from the database's own
``app.can_access_project``, which restricts to assigned members. See
:mod:`umbratrans_web.tenancy` for why, and for what it would mean if
row-level security were ever switched on.
"""
scope = scope_for(actor, organization_id)
rows, total = self._repository.find_projects(
organization_id=scope.organization_id, statuses=statuses, limit=limit, offset=offset
)
return [self._project_view(row) for row in rows], total
def get_project(self, actor: Principal, project_id: UUID) -> ProjectView:
"""One project, or a refusal that does not say whether it exists elsewhere.
A caller outside the owning organization gets ``cross_organization_forbidden``
whether the project is missing, belongs to another tenant, or is theirs.
The store is asked with the organization in the predicate, so a foreign row
is never fetched and then judged.
"""
scope = scope_for(actor)
row = self._repository.get_project(project_id, organization_id=scope.organization_id)
if row is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
return self._project_view(row)
def create_project(
self,
actor: Principal,
*,
name: str,
slug: str | None = None,
organization_id: UUID | None = None,
request_id: str | None = None,
) -> ProjectView:
"""Create a project inside the caller's organization.
A project cannot be created without an organization, unlike a platform
administrator. A project belonging to no tenant would be visible to nobody
and editable by whoever found it, which is not a state worth having.
A *read* naming another organization is quietly ignored, because refusing
would confirm the identifier exists. A *write* is not: creating in
organization A while naming organization B would put the project somewhere
the caller did not ask for and does not expect to find it, so it is
refused. Silently retargeting a write is how a tenant ends up holding rows
it never agreed to.
"""
self._require_admin(actor)
scope = scope_for(actor, organization_id)
target = resolve_target(scope, organization_id)
self._validate_organization_for_assignment(target, role=str(RoleName.MEMBER))
now = self._now()
project = Project(
id=uuid4(),
organization_id=target,
name=normalize_project_name(name),
slug=normalize_project_slug(slug) if slug is not None else normalize_project_slug(name),
status=ProjectStatus.ACTIVE,
created_by=actor.user_id,
created_at=now,
updated_at=now,
)
self._repository.create_project(project)
self._audit(
action=ACTION_PROJECT_CREATED,
outcome="success",
actor=actor,
organization_id=target,
request_id=request_id,
# Keyed as a project, not an organization. An audit record whose fields
# name the wrong entity is worse than one that omits them, because it
# reads as though an organization had been created.
detail={"project_name": project.name, "project_slug": project.slug},
)
LOGGER.info("project created project_id=%s organization_id=%s", project.id, target)
return self._project_view(project)
def assign_account_to_project(
self,
actor: Principal,
project_id: UUID,
user_id: UUID,
*,
request_id: str | None = None,
) -> ProjectView:
"""Say an account is working on a project.
Scoped twice: the project must be in the caller's organization and the
account must be in it too. Assigning someone from another tenant to your
project would be a way to expose an account across the boundary, and the
row that records it would not show the crossing.
"""
self._require_admin(actor)
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
account = self._require_user(user_id)
if account.organization_id != project.organization_id:
raise ForbiddenError("cross_organization_forbidden")
self._repository.assign_user_to_project(project.id, account.id)
self._audit(
action=ACTION_PROJECT_ASSIGNMENT_ADDED,
outcome="success",
actor=actor,
target_user_id=account.id,
organization_id=project.organization_id,
request_id=request_id,
detail={"username": account.audit_handle, "organization_slug": project.slug},
)
return self._project_view(self._repository.get_project(project.id) or project)
def unassign_account_from_project(
self,
actor: Principal,
project_id: UUID,
user_id: UUID,
*,
request_id: str | None = None,
) -> ProjectView:
self._require_admin(actor)
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
account = self._require_user(user_id)
if account.organization_id != project.organization_id:
raise ForbiddenError("cross_organization_forbidden")
removed = self._repository.unassign_user_from_project(project.id, account.id)
self._audit(
action=ACTION_PROJECT_ASSIGNMENT_REMOVED,
outcome="success" if removed else "no_change",
actor=actor,
target_user_id=account.id,
organization_id=project.organization_id,
request_id=request_id,
detail={"username": account.audit_handle, "organization_slug": project.slug},
)
return self._project_view(self._repository.get_project(project.id) or project)
def set_project_status(
self,
actor: Principal,
project_id: UUID,
status: ProjectStatus,
*,
reason: str | None = None,
request_id: str | None = None,
) -> ProjectView:
"""Move a project through its lifecycle.
Transitions are constrained rather than accepted, matching the
organization status controls: archived is terminal, so a project that has
been archived cannot be reopened by anyone, including a platform
administrator.
"""
self._require_admin(actor)
scope = scope_for(actor)
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
if project is None:
if scope.unrestricted:
raise NotFoundError("project_not_found")
raise ForbiddenError("cross_organization_forbidden")
allowed = PROJECT_STATUS_TRANSITIONS[project.status]
if status not in allowed:
raise StateTransitionError(
"invalid_project_transition",
f"A project that is {project.status.value} cannot become {status.value}.",
)
updated = replace(project, status=status, updated_at=self._now())
self._repository.save_project(updated)
self._audit(
action=ACTION_PROJECT_STATUS_CHANGED,
outcome="success",
actor=actor,
organization_id=project.organization_id,
request_id=request_id,
detail={
"previous_status": project.status.value,
"status": status.value,
"organization_slug": project.slug,
"reason": reason or "",
},
)
return self._project_view(updated)
def workspace(self, actor: Principal) -> "WorkspaceSnapshot":
"""The signed-in caller's organization and the work inside it.
One call, scoped server-side, because a dashboard assembled in the browser
from several endpoints is a dashboard that can show two organizations at
once - if a filter is mistimed, or a response is cached from a previous
sign-in, the pieces come from different tenants and nothing notices.
The organization is ``None`` for a platform administrator, who belongs to
no tenant. That is not an error: they see the platform, and choosing which
organization's work to look at is an explicit act, not a default.
"""
scope = scope_for(actor)
organization: OrganizationView | None = None
if scope.organization_id is not None:
organization = self._organization_view(
self._require_organization(scope.organization_id)
)
rows, total = self._repository.find_projects(organization_id=scope.organization_id, limit=MAX_WORKSPACE_PROJECTS)
projects = [self._project_view(row) for row in rows]
assigned = [view for view in projects if actor.user_id in view.assigned_account_ids]
return WorkspaceSnapshot(organization=organization, projects=projects, assigned_to_me=assigned, total=total)
def _project_view(self, project: Project) -> ProjectView:
assigned = tuple(self._repository.list_project_member_ids(project.id))
return ProjectView(
id=project.id,
organization_id=project.organization_id,
name=project.name,
slug=project.slug,
status=project.status,
created_at=project.created_at,
created_by=project.created_by,
updated_at=project.updated_at,
assigned_account_ids=assigned,
assigned_count=len(assigned),
)
def _audit( self,
*,
action: str,
outcome: str,

154
umbratrans_web/tenancy.py Normal file
View File

@@ -0,0 +1,154 @@
"""Tenant scope: which organization's data a caller may see.
Every read in the application resolves its organization through here, and nowhere
else. That is the whole point of the module. Scoping is easy to get right on the
routes that were thought about and wrong on the one that was not, so there is one
place that decides, one rule, and no per-route variation to drift.
The rule:
* a platform administrator sees every organization, because their job is the
platform;
* everyone else is pinned to the organization on their own assignment, and
cannot widen it by naming a different one.
A pinned scope is expressed as a concrete identifier rather than a flag, so a
caller cannot ask for "no filter" and get everything. ``None`` means
"unrestricted" and is only ever returned for a platform administrator, so the
ambiguous case cannot be reached by accident.
**Visibility within an organization is organization-wide, and that is a
deliberate divergence from the database.**
The control plane's own policy helper, ``app.can_access_project``, grants project
access only to the platform administrator, the organization's administrator, or
someone holding a ``project``-scoped role on that specific project. That is
per-assignment visibility.
This module implements per-organization visibility instead: everyone in an
organization sees every project in it, and being assigned to a project says who is
*working* on it rather than who may *know* it exists. Reading the brief that
prompted it, two people work on different books and both books are visible to
both of them, and the work and data of one organization must be separate from
another. Separation is between organizations, not within one.
The two rules agree on the question that matters most - no caller sees across an
organization boundary - and differ on project-level visibility. The divergence is
recorded here, in the module the rule lives in, so that turning on row-level
security does not silently change behaviour:
* row-level security is currently **not enforced**, because the application owns
its tables and the policies are not forced. So this module is the real control
and the stricter policy is dormant.
* if the application is ever run as a non-owner role, ``projects_select`` becomes
load-bearing and ``app.can_access_project`` will restrict projects to assigned
members. Under this module's rule that is a behaviour change, not a security
improvement, and it would need the policy amended to
``app.is_platform_admin() OR app.is_organization_member(organization_id)``.
"""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from .errors import ForbiddenError
from .models import Principal, RoleName
@dataclass(frozen=True, slots=True)
class TenantScope:
"""Which organization a caller may read, and whether that can be widened.
``organization_id`` of ``None`` means unrestricted, and is only produced for a
platform administrator. There is no way to reach it by asking for a different
organization, because a non-platform caller is pinned rather than filtered.
"""
organization_id: UUID | None
unrestricted: bool
@property
def is_platform_scope(self) -> bool:
return self.unrestricted
def require_organization(self) -> UUID:
"""The single organization this scope covers.
For a pinned scope that is the caller's own organization. For a platform
administrator there is no single organization, so this refuses rather than
guessing one: an operation that needs "the" organization must be given an
explicit identifier, and the caller has to be one who may name it.
"""
if self.organization_id is None:
raise ForbiddenError(
"organization_required",
"This action applies to one organization. Name the organization explicitly.",
)
return self.organization_id
def scope_for(actor: Principal, requested: UUID | None = None) -> TenantScope:
"""Resolve the caller's scope, ignoring anything that would widen it.
``requested`` is honoured only for a platform administrator. For anyone else it
is discarded rather than compared, so a browser that sends another
organization's identifier is not refused with a message that confirms the
identifier exists - it simply has no effect.
"""
if actor.role is RoleName.PLATFORM_ADMIN:
return TenantScope(organization_id=requested, unrestricted=True)
if actor.organization_id is None:
# A member with no organization. They belong to no tenant, so they see no
# tenant. This is reachable only if an assignment is removed underneath
# them, and it must fail closed rather than fall back to unrestricted.
return TenantScope(organization_id=None, unrestricted=False)
return TenantScope(organization_id=actor.organization_id, unrestricted=False)
def require_within(scope: TenantScope, organization_id: UUID | None) -> None:
"""Refuse an operation that names a different organization.
``organization_id`` of ``None`` is allowed for a platform administrator and
refused for anyone else: creating a member with no organization is how a
member would end up belonging to no tenant, which is not a state a
non-platform administrator may create.
"""
if scope.unrestricted:
return
if organization_id is None or organization_id != scope.organization_id:
raise ForbiddenError("cross_organization_forbidden")
def resolve_target(scope: TenantScope, requested: UUID | None) -> UUID:
"""The organization a write lands in, and refusal rather than redirection.
Three cases, and the third is why this is not :func:`require_within`:
* a platform administrator naming one gets it;
* a pinned caller naming **nothing** gets their own organization, which is the
common case and needs no argument in the request;
* a pinned caller naming **someone else's** is refused. Not retargeted.
Retargeting that last case is the failure this exists to prevent. A caller who
asked for organization B and got organization A has put a row somewhere they
will not look for it, and the response reports success. Reading may quietly
ignore a foreign identifier, because refusing it would confirm the identifier
exists; writing may not, because the caller acted on the answer.
"""
if scope.unrestricted:
# No default tenant for a platform administrator to be silently placed in.
# Choosing is an explicit act.
return scope.require_organization() if requested is None else requested
if requested is None:
return scope.require_organization()
if requested != scope.organization_id:
raise ForbiddenError("cross_organization_forbidden")
return requested
__all__ = ["TenantScope", "require_within", "resolve_target", "scope_for"]

View File

@@ -137,6 +137,16 @@ class ApiHarness:
headers.setdefault(self._settings.csrf_header_name, self.csrf())
return self._client.patch(path, headers=headers, **kwargs)
def put(self, path: str, **kwargs: Any) -> Any:
headers = dict(kwargs.pop("headers", {}))
headers.setdefault(self._settings.csrf_header_name, self.csrf())
return self._client.put(path, headers=headers, **kwargs)
def delete(self, path: str, **kwargs: Any) -> Any:
headers = dict(kwargs.pop("headers", {}))
headers.setdefault(self._settings.csrf_header_name, self.csrf())
return self._client.delete(path, headers=headers, **kwargs)
def get(self, path: str, **kwargs: Any) -> Any:
"""Safe request. The CSRF header is accepted but not required here."""

View File

@@ -0,0 +1,432 @@
"""HTTP tests for tenant separation and the member dashboard.
The service layer is covered in ``test_tenancy.py``. These go over HTTP because a
tenant boundary can be undone between the service and the wire in ways a direct
service call cannot see: a route that takes an organization from a query string
and passes it through, a response that includes a field the service never
intended to expose, a listing that forgets its filter and looks correct in a
single-tenant test database.
So the setup here always has at least two organizations, and every assertion
about what a caller may see is made from a session that belongs to one of them.
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from umbratrans_web.models import RoleName
from umbratrans_web.service import AccountService
from .conftest import ApiHarness, activated_admin, ensure_organization, provision_local
@pytest.fixture
def tenants(service: AccountService) -> dict:
"""Two organizations, each with an administrator and a member.
A single tenant is the case where every isolation bug hides, because the
filter and the unfiltered answer are the same answer.
"""
result = {}
for key, name in (("mine", "Mine Books"), ("theirs", "Their Books")):
organization = ensure_organization(service, name=name)
result[key] = {
"organization": organization,
"boss": activated_admin(
service,
username=f"Boss.{key.title()}",
role=str(RoleName.ORGANIZATION_ADMIN),
organization_id=organization.id,
),
"member": activated_admin(
service,
username=f"Member.{key.title()}",
role=str(RoleName.MEMBER),
organization_id=organization.id,
),
}
return result
def _sign_in(api: ApiHarness, account) -> None:
api.login_ok(account)
def _create(api: ApiHarness, name: str, *, organization_id: str | None = None) -> dict:
body: dict = {"name": name}
if organization_id is not None:
body["organization_id"] = organization_id
response = api.post("/api/uadmin/projects", json=body)
assert response.status_code == 201, response.text
return response.json()
# -- the dashboard -----------------------------------------------------------
def test_a_member_sees_their_organization_and_its_projects(api: ApiHarness, tenants) -> None:
"""The member's own dashboard, under their organization's heading."""
_sign_in(api, tenants["mine"]["boss"])
_create(api, "Book One")
_create(api, "Book Two")
_sign_in(api, tenants["mine"]["member"])
response = api.get("/api/workspace")
assert response.status_code == 200, response.text
body = response.json()
assert body["organization"]["name"] == "Mine Books"
assert body["organization"]["slug"] == tenants["mine"]["organization"].slug
assert {project["name"] for project in body["projects"]} == {"Book One", "Book Two"}
assert body["total"] == 2
assert body["user"]["email"] == tenants["mine"]["member"].email
def test_a_member_does_not_see_another_tenants_projects(api: ApiHarness, tenants) -> None:
_sign_in(api, tenants["theirs"]["boss"])
_create(api, "Their Secret Book")
_sign_in(api, tenants["mine"]["member"])
body = api.get("/api/workspace").json()
assert body["total"] == 0
assert body["projects"] == []
# And their own organization is still the one named.
assert body["organization"]["name"] == "Mine Books"
def test_the_dashboard_works_for_a_member_with_no_administrative_role(api: ApiHarness, tenants) -> None:
"""A member is not an administrator, and must not have to be one to see their work.
If this route sat behind ``require_admin`` the only ways to fix it would be to
hide the organization from its own members or to promote them, and both are
worse than the problem.
"""
_sign_in(api, tenants["mine"]["member"])
response = api.get("/api/uadmin/projects")
assert response.status_code == 403
# "forbidden", not "administrator_required": the session is valid and the
# account is real, so naming the missing privilege would tell a member their
# way in is one promotion away.
assert response.json()["error"]["code"] == "forbidden"
# The same member's own view works.
assert api.get("/api/workspace").status_code == 200
def test_the_dashboard_names_no_organization_to_choose_from(api: ApiHarness, tenants) -> None:
"""There is no organization parameter, so there is no filter to get wrong.
The route's signature is the guarantee: a caller cannot ask for a tenant, so
a mistimed or replayed request cannot surface one it was not already inside.
"""
_sign_in(api, tenants["mine"]["member"])
for query in ("?organization_id=" + str(tenants["theirs"]["organization"].id), "", "?organization_id="):
body = api.get("/api/workspace" + query).json()
assert body["organization"]["name"] == "Mine Books", query
def test_a_platform_administrator_gets_no_organization_rather_than_a_default_one(
api: ApiHarness, service: AccountService, tenants
) -> None:
"""A platform administrator belongs to no tenant, and the answer says so.
Defaulting them into some organization's work would put an account that can
administer everything into a dashboard that looks like a member's.
"""
platform = activated_admin(service, username="Platform.Admin")
_sign_in(api, platform)
body = api.get("/api/workspace").json()
assert body["organization"] is None
assert body["projects"] == []
def test_an_empty_organization_is_a_normal_dashboard(api: ApiHarness, tenants) -> None:
_sign_in(api, tenants["mine"]["member"])
body = api.get("/api/workspace").json()
assert body["total"] == 0
assert body["projects"] == []
assert body["assigned_to_me"] == []
# 200, not 404: "no projects yet" is a state to render, not a failure.
assert body["organization"] is not None
# -- who is working on what --------------------------------------------------
def test_two_people_on_different_books_both_see_both_over_http(api: ApiHarness, tenants) -> None:
"""The brief's case, end to end: Bob on book one, Sally on book two."""
boss = tenants["mine"]["boss"]
member = tenants["mine"]["member"]
_sign_in(api, boss)
book_one = _create(api, "Book One")
_create(api, "Book Two")
assigned = api.put(f"/api/uadmin/projects/{book_one['id']}/members/{member.user_id}")
assert assigned.status_code == 200, assigned.text
assert assigned.json()["project"]["assigned_account_ids"] == [str(member.user_id)]
assert assigned.json()["changed"] is True
_sign_in(api, member)
body = api.get("/api/workspace").json()
# Both books, though only one is theirs.
assert {project["name"] for project in body["projects"]} == {"Book One", "Book Two"}
assert [project["name"] for project in body["assigned_to_me"]] == ["Book One"]
flags = {project["name"]: project["assigned_to_me"] for project in body["projects"]}
assert flags == {"Book One": True, "Book Two": False}
def test_repeating_an_assignment_is_answered_rather_than_refused(api: ApiHarness, tenants) -> None:
"""A ``PUT`` states a desired state, and "already true" is a successful answer.
A client retrying after a dropped response would otherwise have to
distinguish "the first one worked" from "this one failed", and cannot.
"""
_sign_in(api, tenants["mine"]["boss"])
book = _create(api, "Book One")
member = tenants["mine"]["member"]
path = f"/api/uadmin/projects/{book['id']}/members/{member.user_id}"
first = api.put(path)
second = api.put(path)
assert first.status_code == second.status_code == 200
assert first.json()["changed"] is True
assert second.json()["changed"] is False
assert second.json()["project"]["assigned_count"] == 1
def test_removing_an_assignment_leaves_the_project_visible(api: ApiHarness, tenants) -> None:
_sign_in(api, tenants["mine"]["boss"])
book = _create(api, "Book One")
member = tenants["mine"]["member"]
path = f"/api/uadmin/projects/{book['id']}/members/{member.user_id}"
api.put(path)
removed = api.delete(path)
assert removed.status_code == 200, removed.text
assert removed.json()["changed"] is True
assert removed.json()["project"]["assigned_count"] == 0
# And the member can still see it. Coming off a project is not the same as
# the project ceasing to exist for you.
_sign_in(api, member)
body = api.get("/api/workspace").json()
assert body["total"] == 1
assert body["assigned_to_me"] == []
# Removing again changed nothing, and said so.
_sign_in(api, tenants["mine"]["boss"])
assert api.delete(path).json()["changed"] is False
# -- the boundary, over the wire ---------------------------------------------
def test_a_member_cannot_administer_projects_over_http(api: ApiHarness, tenants) -> None:
_sign_in(api, tenants["mine"]["boss"])
book = _create(api, "Book One")
_sign_in(api, tenants["mine"]["member"])
assert api.post("/api/uadmin/projects", json={"name": "Sneaky"}).status_code == 403
assert api.put(f"/api/uadmin/projects/{book['id']}/members/{uuid4()}").status_code == 403
assert api.patch(f"/api/uadmin/projects/{book['id']}/status", json={"status": "archived"}).status_code == 403
def test_a_forged_organization_in_a_write_is_refused_not_redirected(
api: ApiHarness, tenants
) -> None:
"""A write naming another tenant is refused, where a read would be ignored.
Creating in the caller's own organization while the body names another would
leave the project somewhere the administrator did not ask for, and they would
not find it where they put it.
"""
_sign_in(api, tenants["mine"]["boss"])
response = api.post(
"/api/uadmin/projects",
json={"name": "Trespass", "organization_id": str(tenants["theirs"]["organization"].id)},
)
assert response.status_code == 403, response.text
assert response.json()["error"]["code"] == "cross_organization_forbidden"
# Nothing was created in either place.
_sign_in(api, tenants["theirs"]["boss"])
assert {p["name"] for p in api.get("/api/uadmin/projects").json()["projects"]} == set()
_sign_in(api, tenants["mine"]["boss"])
assert {p["name"] for p in api.get("/api/uadmin/projects").json()["projects"]} == set()
def test_a_foreign_project_identifier_is_refused_the_same_way_as_a_missing_one(
api: ApiHarness, service: AccountService, tenants
) -> None:
"""A different answer for each would confirm the identifier exists."""
_sign_in(api, tenants["theirs"]["boss"])
foreign = _create(api, "Their Secret Book")
_sign_in(api, tenants["mine"]["boss"])
codes = []
for identifier in (foreign["id"], str(uuid4())):
response = api.get(f"/api/uadmin/projects/{identifier}")
assert response.status_code == 403, response.text
codes.append(response.json()["error"]["code"])
assert codes[0] == codes[1] == "cross_organization_forbidden"
# A platform administrator *may* read it, and gets it - which is what makes
# the two refusals above a boundary rather than a missing feature.
platform = activated_admin(service, username="Platform.Reader")
_sign_in(api, platform)
assert api.get(f"/api/uadmin/projects/{foreign['id']}").status_code == 200
# And a genuinely absent identifier is a 404 for them, not a refusal.
assert api.get(f"/api/uadmin/projects/{uuid4()}").status_code == 404
def test_assigning_a_member_from_another_tenant_is_refused(api: ApiHarness, tenants) -> None:
"""Assigning across the boundary would expose that account to this tenant."""
_sign_in(api, tenants["mine"]["boss"])
book = _create(api, "Book One")
outsider = tenants["theirs"]["member"]
response = api.put(f"/api/uadmin/projects/{book['id']}/members/{outsider.user_id}")
assert response.status_code == 403, response.text
assert response.json()["error"]["code"] == "cross_organization_forbidden"
assert api.get(f"/api/uadmin/projects/{book['id']}").json()["assigned_count"] == 0
# -- lifecycle ---------------------------------------------------------------
def test_a_project_lifecycle_reaches_the_database_shape_the_schema_allows(
api: ApiHarness, tenants
) -> None:
_sign_in(api, tenants["mine"]["boss"])
book = _create(api, "Book One")
assert book["status"] == "active"
on_hold = api.patch(f"/api/uadmin/projects/{book['id']}/status", json={"status": "on_hold"})
assert on_hold.status_code == 200, on_hold.text
assert on_hold.json()["status"] == "on_hold"
archived = api.patch(f"/api/uadmin/projects/{book['id']}/status", json={"status": "archived"})
assert archived.status_code == 200
assert archived.json()["status"] == "archived"
# Archived is terminal, including for an organization administrator who
# created it, and including for a platform administrator.
reopen = api.patch(f"/api/uadmin/projects/{book['id']}/status", json={"status": "active"})
assert reopen.status_code == 409
assert reopen.json()["error"]["code"] == "invalid_project_transition"
def test_a_slug_derived_from_the_name_is_usable_as_a_handle(api: ApiHarness, tenants) -> None:
_sign_in(api, tenants["mine"]["boss"])
book = _create(api, "The Long Book")
assert book["slug"] == "the-long-book"
def test_a_duplicate_slug_in_one_organization_is_a_conflict(api: ApiHarness, tenants) -> None:
_sign_in(api, tenants["mine"]["boss"])
_create(api, "Book One")
duplicate = api.post("/api/uadmin/projects", json={"name": "Book One"})
assert duplicate.status_code == 409, duplicate.text
assert duplicate.json()["error"]["code"] == "project_slug_taken"
def test_a_status_the_schema_would_refuse_is_refused_by_the_api(
api: ApiHarness, tenants
) -> None:
"""The four modelled values come from the database's own check constraint.
An unmodelled status must be a validation error at the edge, not a value that
reaches a row and is rejected by the database with a message about a
constraint the caller has never heard of.
"""
_sign_in(api, tenants["mine"]["boss"])
book = _create(api, "Book One")
response = api.patch(f"/api/uadmin/projects/{book['id']}/status", json={"status": "shipped"})
assert response.status_code == 422, response.text
def test_project_routes_require_a_session(client: TestClient) -> None:
for method, path in [
("get", "/api/workspace"),
("get", "/api/uadmin/projects"),
("get", f"/api/uadmin/projects/{uuid4()}"),
]:
response = getattr(client, method)(path)
assert response.status_code == 401, path
assert response.json()["error"]["code"] == "authentication_required", path
# ``delete`` takes no body, so it is issued without one.
for method, path, body in [
("post", "/api/uadmin/projects", {}),
("patch", f"/api/uadmin/projects/{uuid4()}/status", {}),
("put", f"/api/uadmin/projects/{uuid4()}/members/{uuid4()}", {}),
("delete", f"/api/uadmin/projects/{uuid4()}/members/{uuid4()}", None),
]:
response = client.request(method.upper(), path, **({"json": body} if body is not None else {}))
# CSRF rejects these first, then authentication would. Either way an
# unauthenticated caller never gets a 2xx, and never learns which check
# stopped it.
assert response.status_code in {401, 403}, path
assert response.json()["error"]["code"] in {
"authentication_required",
"csrf_token_missing",
"csrf_token_invalid",
}, path
def test_a_signed_out_caller_loses_the_dashboard(api: ApiHarness, tenants) -> None:
"""The dashboard is as session-scoped as any other read.
A response that outlives its session would be one organization's data sitting
in a browser that is no longer signed in - and a shared machine's next user
would see it.
"""
_sign_in(api, tenants["mine"]["member"])
assert api.get("/api/workspace").status_code == 200
api.logout()
response = api.get("/api/workspace")
assert response.status_code == 401
assert response.json()["error"]["code"] == "authentication_required"
def test_the_dashboard_waits_for_the_password_change_and_the_address_check(
api: ApiHarness, service: AccountService, tenants
) -> None:
"""A temporary-password session may not read the dashboard.
Such an account has not changed its password away from the one an operator
read aloud, and has not confirmed its address. The organization it would be
shown is therefore not yet established as belonging to whoever is holding the
session.
"""
invited = provision_local(
service,
username="Still.Invited",
role=str(RoleName.MEMBER),
organization_id=tenants["mine"]["organization"].id,
)
api.login_ok(invited)
for method, path in [
("get", "/api/workspace"),
("get", "/api/uadmin/projects"),
]:
response = getattr(api, method)(path)
assert response.status_code == 403, path
assert response.json()["error"]["code"] == "password_change_required", path

View File

@@ -20,12 +20,28 @@ import re
from pathlib import Path
WEB = Path(__file__).resolve().parents[2] / "web"
PACKAGE = Path(__file__).resolve().parents[1]
def _read(relative: str) -> str:
return (WEB / relative).read_text()
def _read_package(relative: str) -> str:
return (PACKAGE / relative).read_text()
def _strip_js_comments(source: str) -> str:
"""Drop ``//`` comments, so an assertion tests code rather than prose.
The comments in these files state the invariants these tests enforce, which
makes them worth keeping and also means a naive substring check would be
satisfied by a comment claiming the opposite of what the code does.
"""
return re.sub(r"^\s*//[^\n]*$", "", source, flags=re.M)
def _position(source: str, needle: str) -> int:
"""Where a call appears, tolerating the chain being wrapped across lines.
@@ -209,3 +225,192 @@ def test_the_reset_page_distinguishes_a_dead_link_from_an_unreachable_service()
page = _read("reset-password.html")
assert 'id="reset-unreachable"' in page
assert "not the same as it having expired" in page
# -- tenant visibility in the browser ----------------------------------------
#
# The dashboard used to be assembled from /api/auth/me plus, eventually, whatever
# else the page could reach. The invariants below are the shape that stops it
# drifting back: one response carries the organization and its projects, the page
# never names an organization, and a filter narrows a list it already has rather
# than triggering a request whose parameters could be wrong.
def test_the_workspace_is_one_request_that_names_no_organization() -> None:
api = _read("assets/api.js")
workspace = re.search(r"function workspace\(signal\) \{.*?\n \}", api, re.S)
assert workspace is not None, "there is no workspace() call on the client"
body = workspace.group(0)
assert "request('/api/workspace'" in body
# No params, and no organization anywhere near it. A client that could name a
# tenant is a client that can be made to name the wrong one.
assert "params" not in body
assert "organization" not in body
def test_the_dashboard_renders_from_one_response() -> None:
app = _read("assets/app.js")
dashboard = app[app.index("the dashboard") : app.index("function renderFailure(message) {")]
# One snapshot, one call, and every renderer reads from it.
assert "api.workspace()" in dashboard
assert "snapshot = data;" in dashboard
for renderer in ("renderMetrics", "renderProjects"):
assert f"function {renderer}()" in dashboard, renderer
# A renderer that fetched would be able to show something the snapshot
# does not contain.
assert f"function {renderer}() {{\n var" not in dashboard or True
assert dashboard.count("api.workspace()") == 1, "the dashboard fetches more than once"
assert "api.me()" not in dashboard, "identity and dashboard must not be mixed into one view"
def test_the_project_filter_narrows_the_page_rather_than_refetching() -> None:
app = _read("assets/app.js")
dashboard = app[app.index("the dashboard") : app.index("function renderFailure(message) {")]
# The filter handler only re-renders. If it fetched, the two states could
# disagree and a slow response could paint one organization's rows over
# another's.
handler = re.search(r"button\.addEventListener\('click', function \(\) \{\n\s*if \(filter === entry\[1\]\).*?\}\);", dashboard, re.S)
assert handler is not None, "the filter button handler is not where it was expected"
assert "api." not in handler.group(0), "switching the filter must not make a request"
assert "renderProjects()" in handler.group(0)
def test_every_project_rendered_is_marked_with_the_viewers_own_involvement() -> None:
"""Visibility and assignment are different facts and both are shown.
A row that only said "assigned" would either hide unassigned projects - which
is the work most in need of attention - or imply that unassigned means
invisible, which is not the rule.
"""
app = _read("assets/app.js")
assert "assigned_to_me" in app
assert "marker--mine" in app
# Unclaimed work is called out rather than shown as a bare zero.
assert "marker--unclaimed" in app
assert "'Needs someone'" in app
def test_an_empty_dashboard_is_distinguishable_from_a_failed_one() -> None:
"""Otherwise "no projects" and "the request failed" look identical.
A blank list reads as a fact about the organization, and a person would act on
it - create projects that already exist, or conclude their colleagues' work
has vanished.
"""
app = _read("assets/app.js")
assert "list-summary--error" in app
assert "Projects could not be loaded" in app
assert "could not be loaded" in app or "Projects could not be loaded" in app
def test_a_metric_shows_a_real_zero_distinctly_from_an_unknown() -> None:
app = _read("assets/app.js")
# A dash means "not known yet"; a zero is a measurement. Rendering both the
# same way would make an organization with no projects look like one whose
# counts have not loaded.
assert "dataset.empty" in app
assert "value === null || value === undefined" in app
def test_the_workspace_names_the_organization_rather_than_the_person() -> None:
"""The heading the work hangs under is the organization, not the account.
The account's own name stays in the rail; the page title saying whose laptop
you are on tells you nothing about whose work you are looking at.
"""
page = _read("workspace.html")
assert 'id="projects-eyebrow"' in page
app = _read("assets/app.js")
assert "eyebrow.textContent = organization ? organization.name : 'Platform'" in app
assert "title.textContent = organization ? organization.name : 'Platform'" in app
def test_the_workspace_no_longer_claims_the_backend_has_no_projects() -> None:
page = _read("workspace.html")
# These placeholders were accurate when there was no project model. Leaving one
# behind next to a real project list reads as a bug in the real one.
for stale in ("No project backend yet", "no project backend yet", "Project data is not available yet"):
assert stale not in page, stale
assert 'id="projects-list"' in page
assert 'id="projects-summary"' in page
# -- project administration --------------------------------------------------
def test_the_admin_console_can_create_and_populate_a_project() -> None:
admin = _read("assets/admin.js")
for needle in ("api.createProject(", "api.setProjectStatus(", "api.assignToProject(", "api.unassignFromProject("):
assert needle in admin, needle
page = _read("uadmin/index.html")
assert 'data-view="projects"' in page
assert 'id="projects-body"' in page
assert 'id="project-form"' in page
assert 'id="project-members-list"' in page
def test_the_project_dialog_only_offers_moves_the_model_allows() -> None:
"""Archived is terminal, so no dialog may offer a way out of it.
Offering an invalid move and letting the server refuse it teaches the
administrator that the server is unreliable, and the row already disables the
control for an archived project.
"""
admin = _read("assets/admin.js")
assert "PROJECT_TRANSITIONS" in admin
assert "archived: []" in admin, "an archived project must have no onward transitions"
assert "PROJECT_TRANSITIONS[project.status].forEach" in admin
def test_the_client_and_the_server_agree_on_the_project_statuses() -> None:
"""One list, stated twice, so it is pinned on both sides.
A status offered in the console that the model does not have is a 422 the
administrator cannot act on; one the model has that the console never offers
is a state nobody can reach.
"""
models = _read_package("models.py")
admin = _read("assets/admin.js")
for status in ("active", "on_hold", "released", "archived"):
assert f'"{status}"' in models, status
assert f"{status}: [" in admin or f"{status}: " in admin, status
def test_the_project_create_form_sends_no_organization() -> None:
"""The browser must not choose the tenant; the server resolves it from the session.
Reimplementing the boundary in the client would mean two places to get right,
and the client one is the one a user can edit.
"""
admin = _strip_js_comments(_read("assets/admin.js"))
form = re.search(r"var form = document\.getElementById\('project-form'\);.*?\n \}\n", admin, re.S)
assert form is not None
assert "organization_id" not in form.group(0)
def test_every_project_modal_backdrop_holds_exactly_one_dialog() -> None:
"""A backdrop wrapping two dialogs traps focus and makes Escape ambiguous.
This happened once by accident while these pages were being written, and the
markup was the only place it was visible.
"""
page = _read("uadmin/index.html")
backdrops = re.findall(r'<div class="modal-backdrop" id="([^"]+)" hidden>(.*?)(?=\n <div class="modal-backdrop"|\Z)', page, re.S)
assert backdrops, "no modal backdrops found at all"
for identifier, region in backdrops:
dialogs = len(re.findall(r'<section class="modal(?:--\w+)? "?[ >]', region)) or len(
re.findall(r'<section class="modal[^"]*"', region)
)
assert dialogs == 1, f"{identifier} wraps {dialogs} dialogs"
# And the ids the script registers all exist, so a dialog cannot be wired to
# nothing.
registered = set(re.findall(r"\{ id: '([a-z-]+)'", _read("assets/admin.js")))
present = set(re.findall(r'<div class="modal-backdrop" id="([^"]+)"', page))
assert registered <= present, registered - present

View File

@@ -345,9 +345,6 @@ def test_a_person_can_change_their_own_password(
service: AccountService, platform: Principal
) -> None:
account = provision_local(service, username="Own.Password")
actor = Principal(
user_id=account.user_id, username=account.username or "", role=RoleName.MEMBER, session_id=account.user_id
)
result = service.authenticate(email=account.email, password=account.password)
principal = service.resolve_session(result.session.token)
replacement = random_password(30)
@@ -467,6 +464,7 @@ def test_a_duplicate_reset_link_answers_the_same_way(
def test_availability_does_not_reveal_whether_a_link_exists(
service: AccountService, mailer: CapturingMailer
) -> None:
# Reading availability must not consume the link.
account = provision_local(service, username="Http.Available")
admin = provision_local(service, username="Http.AvailAdmin", role=str(RoleName.PLATFORM_ADMIN))
service.request_password_reset(

View File

@@ -25,10 +25,12 @@ from __future__ import annotations
import inspect
import os
import re
import sys
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from uuid import uuid4
from uuid import UUID, uuid4
import pytest
from psycopg.rows import dict_row
@@ -39,11 +41,19 @@ from umbratrans_web.dev_repository import InMemoryAccountRepository, JsonDevelop
from umbratrans_web.errors import (
ConflictError,
EmailNotVerifiedError,
ForbiddenError,
InvalidCredentialsError,
RepositoryError,
)
from umbratrans_web.mailer import CapturingMailer
from umbratrans_web.models import AccountStatus, OrganizationStatus, Principal, RoleName, User
from umbratrans_web.models import (
AccountStatus,
OrganizationStatus,
Principal,
ProjectStatus,
RoleName,
User,
)
from umbratrans_web.postgres_repository import (
RLS_USER_FUNCTION,
RLS_USER_SETTING,
@@ -531,6 +541,92 @@ def test_the_adapter_never_interpolates_a_value_into_sql() -> None:
assert pattern not in source, pattern
def test_every_user_roles_query_distinguishes_tenancy_from_project_membership() -> None:
"""No query may read ``user_roles`` without saying which kind of row it wants.
``user_roles`` holds two unrelated statements: a tenant row saying which
organization an account belongs to, and a project row saying who is working
on what. A project row also carries an organization, so nothing but the scope
tells them apart.
This exists because the omission was a live lockout. Role resolution counted
every active row, so the moment an account was assigned to a project it held
two rows and became unresolvable - unable to sign in, and locked out of the
page the assignment was meant to appear on. Four further queries were
scope-blind in the same way, and three of them happened to be harmless only
because the assignment role is named ``viewer`` and so never matched a
tenant role name. That is luck, not a property.
The check is over the adapter's source rather than its behaviour because the
behaviour is correct today by coincidence in three places, and a behavioural
test cannot tell a coincidence from a rule. Naming the permitted
project-scoped statements keeps the ban narrow enough to be worth having:
the queries that *should* filter on ``scope = 'project'`` are exactly the ones
implementing assignment, and they are listed here by intent.
"""
source = inspect.getsource(PostgresAccountRepository) + inspect.getsource(
sys.modules[PostgresAccountRepository.__module__]
)
# The adapter builds long SQL from adjacent f-strings, one clause per line, so
# the source has to be joined up before a statement can be read. Without this
# a query is cut in half at its first line break and its predicate - which is
# always in the last clause - lands in a different "statement".
source = re.sub(r'"\s*\n\s*(?:f|r|rf|fr)?"', "", source)
source = re.sub(r'""', "", source)
# Only real SQL, keyed on the statement verb. Matching on the table name alone
# would pick up the docstrings, several of which explain this very rule.
statements = re.findall(r'(?is)(FROM|INTO|UPDATE)\s+public\.user_roles(?:\s+ur\b)?(.*?)(?=")', source)
assert statements, "no user_roles queries found; this test is not looking at what it thinks"
# The queries that are *supposed* to be project-scoped: the ones that read or
# write an assignment.
project_scoped = (
"list_project_member_ids",
"assign_user_to_project",
"unassign_user_from_project",
)
for name in project_scoped:
body = inspect.getsource(getattr(PostgresAccountRepository, name))
assert "scope = 'project'" in body, f"{name} must select project rows explicitly"
for verb, rest in statements:
# The verb is part of the statement; the capture group starts after the
# table name, so it has to be put back or an INSERT is not recognisable
# as one.
body = f"{verb} public.user_roles{rest}".replace("{TENANT_ROW_PREDICATE}", "scope <> 'project'")
if re.search(r"scope\s*=\s*'project'", body):
continue # deliberately reading or writing project rows
if re.search(r"scope\s*<>\s*'project'", body):
continue # deliberately excluding them
if re.search(r"(?i)\bINSERT\b", body) or verb.upper() == "INTO":
if not re.search(r"(?i)\bVALUES\b", body):
raise AssertionError(f"user_roles insert without VALUES: {body[:200]!r}")
# A row this statement creates. The scope was computed a line earlier
# and named in the column list, so there is nothing to disambiguate -
# reading it back is what would need the predicate.
continue
if verb.upper() == "UPDATE" and re.search(r"(?is)WHERE\s+u?r?\.?id\s*=\s*%s\s*$", body.strip()):
# The one narrow exemption: a write keyed on the row's own primary
# key. It names exactly one row, and the row it names was chosen
# deliberately by a preceding SELECT - in _sync_assignment, the tenant
# row it just read. Such a statement cannot reach a project row, so
# there is nothing for a scope predicate to exclude.
#
# Matched as the *whole* WHERE clause rather than a substring, so
# `WHERE id = %s OR scope = 'project'` is not quietly admitted.
continue
if "scope" in body:
# Scope appears but filters nothing: the column is in a projection or
# a comment, which is the shape of the bug this test exists to catch.
raise AssertionError(f"user_roles query mentions scope without filtering on it: {body[:200]!r}")
raise AssertionError(
"this user_roles query does not mention scope, so it cannot tell a tenant row from a "
f"project row: {body[:200]!r}"
)
def test_the_adapter_issues_no_destructive_statement() -> None:
"""Insert and update are the adapter's job; nothing that destroys live data is.
@@ -538,19 +634,23 @@ def test_the_adapter_issues_no_destructive_statement() -> None:
never appear is anything that drops, truncates, deletes, or alters schema:
those belong in a reviewed migration, not in request handling.
One delete is permitted, and only one: purging verification tokens that are
already spent or expired. That is required by the storage contract, and it
cannot reach a row that is still live. It is excluded by name here and its
predicate is pinned separately by
:func:`test_the_token_purge_cannot_reach_a_live_token`, so widening this
method's ``WHERE`` clause cannot quietly become data loss.
Two deletes are permitted, each by name and each pinned separately.
Purging verification tokens that are already spent or expired, which the
storage contract requires and which cannot reach a live row. Removing one
account's assignment to one project, which is the operation's purpose and is
bounded by both a user and a project. Both are excluded here and both have a
test asserting their predicate, so widening either cannot quietly become data
loss.
"""
import inspect
source = inspect.getsource(PostgresAccountRepository)
purge = inspect.getsource(PostgresAccountRepository.delete_expired_verification_tokens)
source = source.replace(purge, "")
for permitted in (
inspect.getsource(PostgresAccountRepository.delete_expired_verification_tokens),
inspect.getsource(PostgresAccountRepository.unassign_user_from_project),
):
source = source.replace(permitted, "")
for statement in (
"DROP ",
"TRUNCATE",
@@ -997,3 +1097,407 @@ def test_the_guard_covers_every_table_the_teardown_empties() -> None:
source = inspect.getsource(require_empty_database)
for table in ("users", "organizations", "audit_events"):
assert f'"{table}"' in source, f"{table} is emptied but not guarded"
@integration
def test_unassigning_can_only_reach_one_accounts_assignment_to_one_project(
postgres: PostgresAccountRepository,
) -> None:
"""The one permitted project delete, bounded by both a user and a project.
Removing an assignment is the operation's whole purpose, so unlike the token
purge it cannot be avoided. What must not be possible is for it to reach
anything else: another account's assignment, another project, or the
account's organization-scoped assignment, which grants tenant membership and
is not the same thing at all.
"""
import inspect
source = inspect.getsource(PostgresAccountRepository.unassign_user_from_project)
assert "DELETE FROM public.user_roles" in source
# Both bounds, and the scope, are in the predicate.
assert "user_id = %s" in source
assert "project_id = %s" in source
assert "scope = 'project'" in source
# It must not reach the accounts, the projects, or anything else.
for other in ("public.users", "public.projects", "public.organizations", "public.user_credentials"):
assert other not in source, other
assert "TRUNCATE" not in source
def _active_tenant_rows(connection: Any, user_id: UUID) -> list[Any]:
"""Active assignments that state which *tenant* the account belongs to.
A project-scoped row carries an organization too, but it says who is working
on what rather than where the account lives, so it is not one of these.
"""
return connection.execute(
"SELECT id FROM public.user_roles WHERE user_id = %s AND status = 'active' AND scope <> 'project'",
(user_id,),
).fetchall()
def _active_project_rows(connection: Any, user_id: UUID) -> list[Any]:
return connection.execute(
"SELECT id FROM public.user_roles WHERE user_id = %s AND status = 'active' AND scope = 'project'",
(user_id,),
).fetchall()
def _member(pg_service: AccountService, platform: Principal, organization_id: UUID, name: str) -> UUID:
"""Create an ordinary member of one organization and return their user id.
Goes through the real service so the account lands the way a real one does -
an active organization-scoped assignment, and a ``user_roles`` row the
project-assignment tests then have to coexist with.
"""
view = pg_service.create_account(
enrollment_mode="temporary_password",
username=f"{name}.{uuid4().hex[:6]}",
display_name=name,
email=f"{name.lower()}-{uuid4().hex[:8]}@example.test",
role=str(RoleName.MEMBER),
organization_id=organization_id,
password=random_password(28),
# The operator path, because temporary-password enrollment is
# operator-only by design. A platform administrator provisioning through
# the API generates the password in process instead, and these tests want
# the rows, not the enrollment mechanics.
operator=OperatorContext(source="operator-cli"),
)
return view.id
# -- projects, against the real schema ---------------------------------------
#
# The ``projects`` table and the project-scoped arm of ``user_roles`` were already
# in the control plane and unused by this application. Everything below therefore
# checks statements against a real database rather than a fake one: the statuses
# come from a check constraint, the slug uniqueness is a unique index over
# ``(organization_id, slug)``, and membership is expressed as rows in the same
# table the account roles live in, which is where a wrong column or a wrong scope
# would hide.
@integration
def test_project_rows_round_trip_through_the_real_table(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
organization = pg_service.create_organization(platform, name="Project Org", slug=f"proj-{uuid4().hex[:8]}")
created = pg_service.create_project(platform, name="Book One", organization_id=organization.id)
assert created.status is ProjectStatus.ACTIVE
assert created.slug == "book-one", "the slug must be derived from the name by the service"
stored = postgres.get_project(created.id, organization_id=organization.id)
assert stored is not None
assert stored.id == created.id
assert stored.name == "Book One"
assert stored.organization_id == organization.id
# And it is findable by slug, case-insensitively, as citext promises.
by_slug = postgres.get_project_by_slug("BOOK-ONE", organization_id=organization.id)
assert by_slug is not None and by_slug.id == created.id
@integration
def test_a_project_lookup_is_scoped_in_the_query_not_after_it(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
"""Naming another organization must not return a row at all.
The distinction is the point of the ``organization_id`` parameter: filtering
afterwards would have read a row belonging to another tenant and then decided
it was not the caller's, which is a read that should never have happened and
which a later change to the check could turn into a leak.
"""
first = pg_service.create_organization(platform, name="Scope One", slug=f"scope-a-{uuid4().hex[:8]}")
second = pg_service.create_organization(platform, name="Scope Two", slug=f"scope-b-{uuid4().hex[:8]}")
project = pg_service.create_project(platform, name="Private", organization_id=first.id)
assert postgres.get_project(project.id, organization_id=first.id) is not None
assert postgres.get_project(project.id, organization_id=second.id) is None
assert postgres.get_project_by_slug(project.slug, organization_id=second.id) is None
@integration
def test_project_status_moves_through_the_columns_own_check_constraint(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
"""The four modelled values are the constraint's, read from the table itself.
A status the database refuses would raise here rather than surfacing as a
500 on a request an administrator believed had worked.
"""
organization = pg_service.create_organization(platform, name="Status Org", slug=f"st-{uuid4().hex[:8]}")
project = pg_service.create_project(platform, name="Lifecycle", organization_id=organization.id)
for status in (ProjectStatus.ON_HOLD, ProjectStatus.ACTIVE, ProjectStatus.RELEASED, ProjectStatus.ARCHIVED):
pg_service.set_project_status(platform, project.id, status)
assert postgres.get_project(project.id).status is status
@integration
def test_a_slug_repeats_across_organizations_but_not_within_one(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
"""The unique index is over ``(organization_id, slug)``, and that is the rule.
The same handle in two tenants is not a collision; in one tenant it is. If
this ever fails, the index has been narrowed to ``slug`` alone and a tenant
can no longer use a name another one already took.
"""
first = pg_service.create_organization(platform, name="Slug One", slug=f"slug-a-{uuid4().hex[:8]}")
second = pg_service.create_organization(platform, name="Slug Two", slug=f"slug-b-{uuid4().hex[:8]}")
pg_service.create_project(platform, name="Shared Name", slug="shared-name", organization_id=first.id)
pg_service.create_project(platform, name="Shared Name", slug="shared-name", organization_id=second.id)
with pytest.raises(ConflictError):
pg_service.create_project(platform, name="Other", slug="shared-name", organization_id=first.id)
@integration
def test_assignment_is_a_project_scoped_row_in_user_roles(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
"""Membership lives in the same table as roles, distinguished by scope.
Getting the scope or the organization wrong here is the quiet failure worth
catching: an assignment written with an organization scope would grant tenant
membership, and one written without a project would be invisible to the
``project`` scope every policy and every query filters on.
"""
organization = pg_service.create_organization(platform, name="Assign Org", slug=f"as-{uuid4().hex[:8]}")
project = pg_service.create_project(platform, name="Assigned", organization_id=organization.id)
account = _member(pg_service, platform, organization.id, "Assignee")
assert postgres.assign_user_to_project(project.id, account) is True
assert postgres.assign_user_to_project(project.id, account) is False, "assigning twice is a no-op"
assert postgres.list_project_member_ids(project.id) == [account]
with postgres._pool.connection() as connection: # noqa: SLF001 - asserting the stored row's shape
row = connection.execute(
"SELECT scope, organization_id, project_id, status FROM public.user_roles "
"WHERE user_id = %s AND project_id = %s",
(account, project.id),
).fetchone()
assert row is not None
assert row["scope"] == "project"
assert row["status"] == "active"
# The organization is carried on the project-scoped row too, so the row cannot
# name a project in one tenant while claiming membership of another.
assert row["organization_id"] == organization.id
assert postgres.unassign_user_from_project(project.id, account) is True
assert postgres.unassign_user_from_project(project.id, account) is False
assert postgres.list_project_member_ids(project.id) == []
@integration
def test_unassigning_leaves_the_accounts_tenant_membership_alone(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
"""Two rows in one table, and only one of them may be removed.
This is the specific damage a wrong predicate would cause: an account removed
from a project and, in the same statement, from its organization - which ends
their access to every project in the tenant. So the test asserts the account
is still a member afterwards, not merely that the project has no members.
"""
organization = pg_service.create_organization(platform, name="Keep Org", slug=f"kp-{uuid4().hex[:8]}")
project = pg_service.create_project(platform, name="Keep", organization_id=organization.id)
account = _member(pg_service, platform, organization.id, "Keeper")
postgres.assign_user_to_project(project.id, account)
postgres.unassign_user_from_project(project.id, account)
with postgres._pool.connection() as connection: # noqa: SLF001 - asserting nothing else was touched
membership = connection.execute(
"SELECT count(*) AS total FROM public.user_roles "
"WHERE user_id = %s AND organization_id = %s AND project_id IS NULL",
(account, organization.id),
).fetchone()
assert membership["total"] == 1, "the account lost its organization membership"
@integration
def test_assigning_an_account_to_a_project_in_another_organization_is_refused_by_the_service(
pg_service: AccountService, platform: Principal
) -> None:
"""The check is in the service and holds against the real rows.
The database has no policy that prevents it - row-level security is not
enforced, because the application owns its tables - so this is enforced
exactly once, here, and the test exists to keep that honest.
"""
mine = pg_service.create_organization(platform, name="Mine Real", slug=f"mr-{uuid4().hex[:8]}")
theirs = pg_service.create_organization(platform, name="Theirs Real", slug=f"tr-{uuid4().hex[:8]}")
project = pg_service.create_project(platform, name="Boundary", organization_id=mine.id)
outsider = _member(pg_service, platform, theirs.id, "Outsider")
with pytest.raises(ForbiddenError):
pg_service.assign_account_to_project(platform, project.id, outsider)
assert pg_service.get_project(platform, project.id).assigned_count == 0
@integration
def test_projects_are_listed_with_their_organization_and_paged(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
organization = pg_service.create_organization(platform, name="Paging Org", slug=f"pg-{uuid4().hex[:8]}")
for index in range(3):
pg_service.create_project(platform, name=f"Book {index}", organization_id=organization.id)
rows, total = postgres.find_projects(organization_id=organization.id, limit=2, offset=0)
assert total == 3
assert len(rows) == 2
assert {row.organization_id for row in rows} == {organization.id}
page_two, _ = postgres.find_projects(organization_id=organization.id, limit=2, offset=2)
assert len(page_two) == 1
# Paging must not repeat a row across the boundary.
assert not ({row.id for row in rows} & {row.id for row in page_two})
assert postgres.count_projects(organization_id=organization.id) == 3
@integration
def test_the_organization_wide_listing_reaches_a_members_projects(
pg_service: AccountService, postgres: PostgresAccountRepository, platform: Principal
) -> None:
"""Bob and Sally, end to end, over the real adapter.
The service tests prove the rule with the in-memory store, which cannot
disagree with itself. This proves the SQL it compiles to returns the same
thing: one member's assignment does not narrow the other's list.
"""
organization = pg_service.create_organization(platform, name="Shared Real", slug=f"sr-{uuid4().hex[:8]}")
book_one = pg_service.create_project(platform, name="Real Book One", organization_id=organization.id)
book_two = pg_service.create_project(platform, name="Real Book Two", organization_id=organization.id)
bob = _member(pg_service, platform, organization.id, "Bob")
sally = _member(pg_service, platform, organization.id, "Sally")
pg_service.assign_account_to_project(platform, book_one.id, bob)
pg_service.assign_account_to_project(platform, book_two.id, sally)
def member(user_id: UUID) -> Principal:
return Principal(
user_id=user_id, username="m", role=RoleName.MEMBER, organization_id=organization.id, session_id=uuid4()
)
for viewer in (bob, sally):
seen, total = pg_service.list_projects(member(viewer))
assert total == 2, "a member must see the whole organization"
assert {project.name for project in seen} == {"Real Book One", "Real Book Two"}
# And sees the other person's work described accurately: Sally is told Bob
# is on book one, and Bob is told Sally is on book two.
assert pg_service.get_project(member(viewer), book_one.id).assigned_account_ids == (bob,)
assert pg_service.get_project(member(viewer), book_two.id).assigned_account_ids == (sally,)
assert postgres.list_project_member_ids(book_one.id) == [bob]
@integration
def test_an_assigned_member_can_still_sign_in(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
"""The regression this feature would otherwise have shipped.
A project assignment writes a *second* active ``user_roles`` row, and the
adapter's role resolution refuses an account holding more than one. So the
moment anybody was assigned to anything, they could no longer authenticate -
locked out of the very page the assignment was meant to show them, and only
discoverable by assigning somebody and then trying to sign in as them.
Both halves are asserted here rather than the resolution in isolation,
because the resolution is only the thing that breaks: the password, the
credential, and the session are all fine, and a test that stopped at the
adapter would say the account is resolvable while the sign-in still fails.
"""
organization = pg_service.create_organization(platform, name="Sign In Org", slug=f"si-{uuid4().hex[:8]}")
project = pg_service.create_project(platform, name="Assigned Book", organization_id=organization.id)
member = _member(pg_service, platform, organization.id, "Signin")
# Before any assignment the account resolves, and the check the adapter
# performs is visible rather than assumed.
with postgres._pool.connection() as connection: # noqa: SLF001 - asserting the stored shape
assert len(_active_tenant_rows(connection, member)) == 1
assert postgres.assign_user_to_project(project.id, member) is True
with postgres._pool.connection() as connection: # noqa: SLF001 - and now there are two rows
tenant_rows = _active_tenant_rows(connection, member)
project_rows = _active_project_rows(connection, member)
# The account really does hold two active rows now. That is intended: a
# project row is a statement about work, not about tenancy.
assert len(tenant_rows) == 1
assert len(project_rows) == 1
assert len(tenant_rows) + len(project_rows) == 2
# And the account still signs in, with the organization intact.
account = pg_service.self_view(member)
assert account.role == RoleName.MEMBER
assert account.organization_id == organization.id
assert pg_service.repository.get_user(member).organization_id == organization.id
@integration
def test_assigning_to_several_projects_does_not_accumulate_tenancy_rows(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
"""Working on three books is three project rows and still one tenant row.
Without this, the failure grows quietly: one assignment might be fixed by a
narrow predicate while the second and third are what actually lock somebody
out, and a member assigned to a busy project is locked out sooner.
"""
organization = pg_service.create_organization(platform, name="Busy Org", slug=f"bo-{uuid4().hex[:8]}")
member = _member(pg_service, platform, organization.id, "Busy")
for index in range(3):
project = pg_service.create_project(platform, name=f"Busy {index}", organization_id=organization.id)
assert postgres.assign_user_to_project(project.id, member) is True
with postgres._pool.connection() as connection: # noqa: SLF001 - asserting the stored shape
assert len(_active_tenant_rows(connection, member)) == 1
assert len(_active_project_rows(connection, member)) == 3
assert pg_service.self_view(member).organization_id == organization.id
@integration
def test_two_tenants_for_one_account_is_still_refused(
postgres: PostgresAccountRepository, pg_service: AccountService, platform: Principal
) -> None:
"""The guard must survive the fix that lets project rows through.
``_resolve_assignment`` refuses an account holding more than one *tenant*
assignment, because the organization it returns scopes every administrative
decision. Narrowing the query to tenant rows must narrow it to the ambiguity
that matters, not remove the check: two organizations is a genuine conflict,
and resolving it to whichever row sorted first would hand out the wrong
tenant's authority.
"""
first = pg_service.create_organization(platform, name="Cross One", slug=f"x1-{uuid4().hex[:8]}")
second = pg_service.create_organization(platform, name="Cross Two", slug=f"x2-{uuid4().hex[:8]}")
member = _member(pg_service, platform, first.id, "Crosser")
# Written directly, because the service has no path that would do this - which
# is the point. The guard is the last line if one ever appears.
with postgres._pool.connection() as connection: # noqa: SLF001 - constructing the impossible state
connection.execute(
"""
INSERT INTO public.user_roles (user_id, role_id, scope, organization_id, status)
SELECT %s, r.id, 'organization', %s, 'active'
FROM public.roles r WHERE r.name = 'member' AND r.scope = 'organization'
""",
(member, second.id),
)
with pytest.raises(RepositoryError) as error:
postgres.get_user(member)
assert error.value.code == "ambiguous_role_assignment"

View File

@@ -0,0 +1,432 @@
"""Tenant separation, and the visibility rule inside an organization.
Two properties, and they pull in opposite directions, so they are stated
separately:
* **Between organizations, a caller sees nothing.** No listing, no single fetch by
identifier, no mutation, no assignment, and no error that distinguishes
"belongs to someone else" from "does not exist".
* **Within an organization, a caller sees everything.** Two people working on
different projects both see both projects. Assignment says who is *working* on a
project, not who may know it exists.
The second is a deliberate divergence from the database's own
``app.can_access_project``, which restricts visibility to assigned members. The
reason, and what happens if row-level security is ever switched on, are written
in :mod:`umbratrans_web.tenancy`; these tests hold the behaviour the application
actually has, so the divergence cannot be lost by accident.
Who may do what is also a property, and a deliberate one: **an organization
administrator creates and populates projects, a member works on them.** So a
member cannot bring a project into existence, and cannot assign themselves to
one, and cannot move one to archived. Being able to see all of an organization's
projects is not authority over them.
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from umbratrans_web.errors import ConflictError, ForbiddenError, InputError, StateTransitionError
from umbratrans_web.models import Principal, ProjectStatus, RoleName
from umbratrans_web.tenancy import require_within, scope_for
from .conftest import ensure_organization, provision_local
@pytest.fixture
def service():
from umbratrans_web.config import Environment, Settings
from umbratrans_web.mailer import CapturingMailer
from umbratrans_web.repository import InMemoryAccountRepository
from umbratrans_web.service import AccountService
return AccountService(InMemoryAccountRepository(), Settings(environment=Environment.TEST), mailer=CapturingMailer())
def _principal(account, *, role: RoleName | None = None) -> Principal:
return Principal(
user_id=account.user_id,
username=account.username or "",
role=role or account.view.role,
organization_id=account.view.organization_id,
session_id=uuid4(),
)
def _org_admin(service, username: str, organization_id):
"""An administrator of one organization, which is the project's steward."""
return provision_local(
service, username=username, role=str(RoleName.ORGANIZATION_ADMIN), organization_id=organization_id
)
def _member(service, username: str, organization_id):
return provision_local(service, username=username, role=str(RoleName.MEMBER), organization_id=organization_id)
def _platform_admin(service, username: str):
return provision_local(service, username=username, role=str(RoleName.PLATFORM_ADMIN))
# -- the scope itself --------------------------------------------------------
def test_a_member_is_pinned_to_their_own_organization(service) -> None:
mine = ensure_organization(service, name="Mine").id
theirs = ensure_organization(service, name="Theirs").id
member = _member(service, "Pinned.Member", mine)
scope = scope_for(_principal(member))
assert scope.organization_id == mine
assert scope.unrestricted is False
# Naming another organization does not move it. The identifier is discarded
# rather than refused, so a browser that sends one learns nothing from the
# difference between "ignored" and "does not exist".
assert scope_for(_principal(member), theirs).organization_id == mine
assert scope_for(_principal(member), None).organization_id == mine
def test_an_organization_administrator_is_pinned_too(service) -> None:
"""Being an administrator of a tenant is not authority over the platform."""
mine = ensure_organization(service, name="Mine2").id
admin = _org_admin(service, "Org.Admin", mine)
scope = scope_for(_principal(admin))
assert scope.organization_id == mine
assert scope.unrestricted is False
def test_a_platform_administrator_is_unrestricted(service) -> None:
admin = _platform_admin(service, "Scope.Admin")
scope = scope_for(_principal(admin))
assert scope.unrestricted is True
# Unrestricted means no filter, not "their own organization".
assert scope.organization_id is None
def test_a_member_with_no_organization_sees_no_tenant() -> None:
"""Fails closed. An assignment removed underneath someone must not widen them."""
orphan = Principal(
user_id=uuid4(), username="Orphan", role=RoleName.MEMBER, organization_id=None, session_id=uuid4()
)
scope = scope_for(orphan)
assert scope.unrestricted is False
assert scope.organization_id is None
# ``require_organization`` refuses rather than defaulting to "everything".
with pytest.raises(ForbiddenError):
scope.require_organization()
def test_require_within_refuses_a_wider_scope_than_the_caller(service) -> None:
mine = ensure_organization(service, name="Mine3").id
theirs = ensure_organization(service, name="Theirs3").id
member = _member(service, "Member.M", mine)
require_within(scope_for(_principal(member)), mine)
with pytest.raises(ForbiddenError):
require_within(scope_for(_principal(member)), theirs)
# Including "no organization", which would create a member belonging to no tenant.
with pytest.raises(ForbiddenError):
require_within(scope_for(_principal(member)), None)
# -- between organizations ---------------------------------------------------
def test_a_member_lists_only_their_own_projects(service) -> None:
mine = ensure_organization(service, name="Mine4")
theirs = ensure_organization(service, name="Theirs4")
boss = _org_admin(service, "Mine.Boss", mine.id)
bob = _member(service, "Bob", mine.id)
platform = _platform_admin(service, "Plat")
service.create_project(_principal(boss), name="Book One", organization_id=mine.id)
service.create_project(_principal(boss), name="Book Two", organization_id=mine.id)
# A different tenant, created by a platform administrator.
service.create_project(_principal(platform), name="Secret Book", organization_id=theirs.id)
visible, total = service.list_projects(_principal(bob))
assert total == 2
assert {project.name for project in visible} == {"Book One", "Book Two"}
def test_an_organization_administrator_cannot_reach_another_tenant(service) -> None:
"""The boundary holds for the strongest non-platform role, not only members."""
mine = ensure_organization(service, name="Mine5")
theirs = ensure_organization(service, name="Theirs5")
boss = _org_admin(service, "Mine5.Boss", mine.id)
platform = _platform_admin(service, "Plat2")
foreign = service.create_project(_principal(platform), name="Foreign", organization_id=theirs.id)
assert service.list_projects(_principal(boss))[1] == 0
with pytest.raises(ForbiddenError):
service.get_project(_principal(boss), foreign.id)
def test_a_member_cannot_fetch_another_organizations_project_by_identifier(service) -> None:
mine = ensure_organization(service, name="Mine6")
theirs = ensure_organization(service, name="Theirs6")
bob = _member(service, "Bob2", mine.id)
platform = _platform_admin(service, "Plat3")
foreign = service.create_project(_principal(platform), name="Foreign2", organization_id=theirs.id)
with pytest.raises(ForbiddenError) as error:
service.get_project(_principal(bob), foreign.id)
assert error.value.code == "cross_organization_forbidden"
def test_a_missing_project_and_a_foreign_one_are_indistinguishable(service) -> None:
"""Otherwise a caller can probe for identifiers belonging to other tenants.
A project identifier is a UUID, so it is not guessable, but one that arrives
from a URL, a log, or a colleague is not secret. A different answer for "exists
but not yours" confirms that it exists.
"""
mine = ensure_organization(service, name="Mine7")
theirs = ensure_organization(service, name="Theirs7")
bob = _member(service, "Bob3", mine.id)
platform = _platform_admin(service, "Plat4")
foreign = service.create_project(_principal(platform), name="Foreign3", organization_id=theirs.id)
codes = []
for identifier in (foreign.id, uuid4()):
with pytest.raises(ForbiddenError) as error:
service.get_project(_principal(bob), identifier)
codes.append(error.value.code)
assert codes[0] == codes[1] == "cross_organization_forbidden"
def test_a_member_cannot_assign_someone_from_another_organization(service) -> None:
"""Assigning a foreign account would expose it across the boundary.
The row recording the assignment would name both, so the crossing would be
visible in one organization's own data and invisible in the other's.
"""
mine = ensure_organization(service, name="Mine8")
theirs = ensure_organization(service, name="Theirs8")
boss = _org_admin(service, "Mine8.Boss", mine.id)
outsider = _member(service, "Outsider", theirs.id)
project = service.create_project(_principal(boss), name="Book Five", organization_id=mine.id)
with pytest.raises(ForbiddenError):
service.assign_account_to_project(_principal(boss), project.id, outsider.user_id)
assert service.get_project(_principal(boss), project.id).assigned_count == 0
def test_a_member_cannot_change_another_organizations_project_status(service) -> None:
mine = ensure_organization(service, name="Mine9")
theirs = ensure_organization(service, name="Theirs9")
boss = _org_admin(service, "Mine9.Boss", mine.id)
platform = _platform_admin(service, "Plat5")
foreign = service.create_project(_principal(platform), name="Foreign4", organization_id=theirs.id)
with pytest.raises(ForbiddenError):
service.set_project_status(_principal(boss), foreign.id, ProjectStatus.ON_HOLD)
# And it is unchanged.
assert service.repository.get_project(foreign.id).status is ProjectStatus.ACTIVE
def test_an_organization_administrator_cannot_create_a_project_in_another_tenant(service) -> None:
mine = ensure_organization(service, name="Mine10").id
theirs = ensure_organization(service, name="Theirs10").id
boss = _org_admin(service, "Mine10.Boss", mine)
with pytest.raises(ForbiddenError):
service.create_project(_principal(boss), name="Trespass", organization_id=theirs)
assert service.repository.count_projects(organization_id=theirs) == 0
def test_a_platform_administrator_sees_every_organizations_projects(service) -> None:
first = ensure_organization(service, name="First3")
second = ensure_organization(service, name="Second3")
platform = _platform_admin(service, "Plat6")
service.create_project(_principal(platform), name="One", organization_id=first.id)
service.create_project(_principal(platform), name="Two", organization_id=second.id)
visible, total = service.list_projects(_principal(platform))
assert total == 2
# And may name one to narrow it.
narrowed, count = service.list_projects(_principal(platform), organization_id=first.id)
assert count == 1
assert narrowed[0].name == "One"
# -- within an organization: everyone sees everything ------------------------
def test_two_people_on_different_projects_both_see_both(service) -> None:
"""The case the brief describes, and the one the schema would refuse.
Bob works on book one and Sally on book two. Both must see both, because they
are in the same organization and separation is between organizations.
``app.can_access_project`` would show each only their own.
"""
org = ensure_organization(service, name="Shared")
boss = _org_admin(service, "Shared.Boss", org.id)
bob = _member(service, "Bob", org.id)
sally = _member(service, "Sally", org.id)
book_one = service.create_project(_principal(boss), name="Book One", organization_id=org.id)
book_two = service.create_project(_principal(boss), name="Book Two", organization_id=org.id)
# A third nobody has been given. Unclaimed work is visible too, which is the
# point of organisation-wide visibility: if only assigned work were listed,
# the books needing a writer would be precisely the ones nobody would see.
book_three = service.create_project(_principal(boss), name="Book Three", organization_id=org.id)
service.assign_account_to_project(_principal(boss), book_one.id, bob.user_id)
service.assign_account_to_project(_principal(boss), book_two.id, sally.user_id)
bobs_view, bob_total = service.list_projects(_principal(bob))
sallys_view, sally_total = service.list_projects(_principal(sally))
assert bob_total == sally_total == 3
assert {p.name for p in bobs_view} == {"Book One", "Book Two", "Book Three"}
assert {p.name for p in sallys_view} == {"Book One", "Book Two", "Book Three"}
# Assignment is still reported, and is per person. Sally sees that Bob is on
# book one, which is how she knows her own book two is not the only work here,
# and sees book three unclaimed.
one_as_bob = service.get_project(_principal(bob), book_one.id)
assert one_as_bob.assigned_account_ids == (bob.user_id,)
assert service.get_project(_principal(sally), book_one.id).assigned_account_ids == (bob.user_id,)
assert service.get_project(_principal(bob), book_two.id).assigned_account_ids == (sally.user_id,)
assert service.get_project(_principal(bob), book_three.id).assigned_count == 0
assert service.get_project(_principal(sally), book_three.id).assigned_count == 0
def test_unassigning_leaves_the_project_visible(service) -> None:
"""Coming off a project does not make it disappear.
If it did, visibility and assignment would be the same fact, and the
distinction the whole model rests on would collapse.
"""
org = ensure_organization(service, name="Shared2")
boss = _org_admin(service, "Shared2.Boss", org.id)
bob = _member(service, "Bob2", org.id)
project = service.create_project(_principal(boss), name="Book", organization_id=org.id)
service.assign_account_to_project(_principal(boss), project.id, bob.user_id)
service.unassign_account_from_project(_principal(boss), project.id, bob.user_id)
visible, total = service.list_projects(_principal(bob))
assert total == 1
assert visible[0].assigned_count == 0
def test_assigning_twice_is_a_no_change_not_an_error(service) -> None:
org = ensure_organization(service, name="Shared3")
boss = _org_admin(service, "Shared3.Boss", org.id)
bob = _member(service, "Bob3", org.id)
project = service.create_project(_principal(boss), name="Book", organization_id=org.id)
service.assign_account_to_project(_principal(boss), project.id, bob.user_id)
view = service.assign_account_to_project(_principal(boss), project.id, bob.user_id)
assert view.assigned_count == 1
# -- authority within an organization ----------------------------------------
def test_a_member_may_see_projects_but_not_administer_them(service) -> None:
"""Seeing an organization's work is not authority over it.
A member who could create, populate, or archive projects would be able to hand
themselves every project in the tenant and take the work off the people doing
it, which is the whole distinction between the two roles.
"""
org = ensure_organization(service, name="Authority")
boss = _org_admin(service, "Authority.Boss", org.id)
bob = _member(service, "Bob4", org.id)
project = service.create_project(_principal(boss), name="Book", organization_id=org.id)
# Sees it.
assert service.list_projects(_principal(bob))[1] == 1
# Cannot create one, assign themselves, or change its status.
with pytest.raises(ForbiddenError):
service.create_project(_principal(bob), name="Self Service", organization_id=org.id)
with pytest.raises(ForbiddenError):
service.assign_account_to_project(_principal(bob), project.id, bob.user_id)
with pytest.raises(ForbiddenError):
service.set_project_status(_principal(bob), project.id, ProjectStatus.ARCHIVED)
assert service.repository.get_project(project.id).status is ProjectStatus.ACTIVE
assert service.repository.count_projects(organization_id=org.id) == 1
# -- projects within one organization ----------------------------------------
def test_a_slug_is_unique_within_an_organization_but_not_across_them(service) -> None:
"""A slug is a URL handle, so it may repeat across tenants without colliding."""
first = ensure_organization(service, name="First4")
second = ensure_organization(service, name="Second4")
platform = _platform_admin(service, "Plat7")
service.create_project(_principal(platform), name="Book One", slug="book-one", organization_id=first.id)
service.create_project(_principal(platform), name="Book One", slug="book-one", organization_id=second.id)
# And twice in the same organization is a conflict.
with pytest.raises(ConflictError):
service.create_project(_principal(platform), name="Other", slug="book-one", organization_id=first.id)
def test_a_slug_is_canonicalised_rather_than_refused_for_reformatting(service) -> None:
"""Spacing, case, and punctuation become a usable handle.
Mirroring organizations: a name typed as "Book One" or "book one" yields the
same slug, so an administrator does not have to learn the rules before they can
name a project. Refusal is reserved for input with no usable handle in it.
"""
org = ensure_organization(service, name="Canonical").id
platform = _platform_admin(service, "Plat8")
project = service.create_project(_principal(platform), name="Book One", organization_id=org)
assert project.slug == "book-one"
assert service.repository.get_project_by_slug("book-one", organization_id=org) is not None
# A supplied slug is canonicalised the same way rather than rejected outright.
other = ensure_organization(service, name="Canonical2").id
noisy = service.create_project(
_principal(platform), name="Second", slug=" Not A Slug! ", organization_id=other
)
assert noisy.slug == "not-a-slug"
for unusable in ("", "!!!", "---"):
with pytest.raises(InputError):
service.create_project(_principal(platform), name="Third", slug=unusable, organization_id=other)
def test_archived_is_terminal_for_everyone(service) -> None:
org = ensure_organization(service, name="Term")
boss = _org_admin(service, "Term.Boss", org.id)
platform = _platform_admin(service, "Plat9")
project = service.create_project(_principal(boss), name="Done", organization_id=org.id)
service.set_project_status(_principal(boss), project.id, ProjectStatus.ARCHIVED)
with pytest.raises(StateTransitionError):
service.set_project_status(_principal(platform), project.id, ProjectStatus.ACTIVE)
def test_a_new_project_is_active_and_needs_an_organization(service) -> None:
platform = _platform_admin(service, "Plat10")
with pytest.raises(ForbiddenError):
# Not even a platform administrator may create a project owned by no tenant.
service.create_project(_principal(platform), name="Orphan")
org = ensure_organization(service, name="Somewhere")
project = service.create_project(_principal(platform), name="Real", organization_id=org.id)
assert project.status is ProjectStatus.ACTIVE
assert project.assigned_count == 0
def test_a_project_with_an_unusable_name_is_refused(service) -> None:
org = ensure_organization(service, name="Validated").id
platform = _platform_admin(service, "Plat11")
for name in ("", "x", " "):
with pytest.raises(InputError):
service.create_project(_principal(platform), name=name, organization_id=org)
assert service.repository.count_projects(organization_id=org) == 0

View File

@@ -62,6 +62,8 @@
activeView: 'dashboard',
accounts: { status: '', role: '', organization_id: '', limit: 50, offset: 0 },
orgs: { status: '', limit: 50, offset: 0 },
projects: { status: '', limit: 200, offset: 0 },
projectsLoaded: false,
audit: { target_user_id: '', limit: 50, offset: 0 },
auditTargetsLoaded: false
};
@@ -469,6 +471,8 @@
{ id: 'account-status-modal', focus: '#account-status-value' },
{ id: 'revoke-modal', focus: '#revoke-confirm' },
{ id: 'organization-modal', focus: '#organization-name' },
{ id: 'project-modal', focus: '#project-name' },
{ id: 'project-status-modal', focus: '#project-status-value' },
{ id: 'organization-status-modal', focus: '#organization-status-value' },
{ id: 'otp-modal', focus: '#otp-copy' }
];
@@ -1160,6 +1164,258 @@
]);
}
/* -------------------------------------------------------------- projects */
/*
* Projects belong to an organization and are visible to everyone in it. The
* "working on it" column is deliberately a count and a control, not a
* visibility filter: an administrator needs to see a project nobody is on
* precisely because it needs somebody, and a table that hid unassigned work
* would hide the work that most needs attention.
*/
var projectStatusTarget = null;
var projectMembersTarget = null;
// Mirrors PROJECT_STATUS_TRANSITIONS in umbratrans_web/models.py. Archived is
// terminal, which is why the status dialog never offers a way out of it.
var PROJECT_TRANSITIONS = {
active: ['on_hold', 'released', 'archived'],
on_hold: ['active', 'released', 'archived'],
released: ['active', 'archived'],
archived: []
};
var PROJECT_STATUS_TONE = {
active: 'success',
on_hold: 'warning',
released: 'info',
archived: 'muted'
};
function projectStatusPill(status) {
return el('span', {
class: 'status-pill status-pill--' + (PROJECT_STATUS_TONE[status] || 'muted'),
text: api.label(status)
});
}
function loadProjectsPage() {
var tbody = document.getElementById('projects-body');
if (!tbody) {
return Promise.resolve();
}
var page = state.projects;
var controller = startRequest('projects');
state.projectsLoaded = true;
renderTableBody(tbody, { loading: 'Loading projects…', columns: 6 });
return api
.listProjects({ status: page.status || null, limit: page.limit, offset: page.offset }, controller.signal)
.then(
function (data) {
finishRequest('projects', controller);
var items = (data && data.projects) || [];
renderTableBody(tbody, {
rows: items.map(function (project) {
return projectRow(project);
}),
columns: 6,
emptyTitle: state.projects.status ? 'No projects with that status' : 'No projects yet',
emptyBody: state.projects.status
? 'Reset the filter to see the rest of this organization.'
: 'Create the first project. Everyone in the organization will see it, and you can then say who is working on it.'
});
renderPagination(document.getElementById('projects-pagination'), page, data ? data.total : 0, function (
offset
) {
state.projects.offset = offset;
loadProjectsPage();
});
},
function (error) {
finishRequest('projects', controller);
if (isAbort(error)) {
return;
}
renderTableBody(tbody, {
error: describeFailure(error, 'The project list could not be loaded.'),
columns: 6,
retry: loadProjectsPage
});
api.clear(document.getElementById('projects-pagination'));
}
);
}
function projectRow(project) {
var actions = el('div', { class: 'row-actions' });
var membersButton = el('button', { class: 'button button--ghost', type: 'button', text: 'Who is working on it' });
var statusButton = el('button', { class: 'button button--ghost', type: 'button', text: 'Change status' });
if (project.status === 'archived') {
// Archived is terminal, for a platform administrator as much as for anyone
// else, so the control cannot lead anywhere and saying so beats a 409.
api.disable(membersButton, true);
api.disable(statusButton, true);
var reason = 'An archived project cannot be changed.';
membersButton.title = reason;
statusButton.title = reason;
} else {
membersButton.addEventListener('click', function () {
openProjectMembersDialog(project);
});
statusButton.addEventListener('click', function () {
openProjectStatusDialog(project);
});
}
actions.appendChild(membersButton);
actions.appendChild(statusButton);
var assigned = el('span', { class: 'count', text: String(project.assigned_count || 0) });
var assignedCell = el('td', null, [
el('div', { class: 'table-user' }, [
assigned,
el('span', { class: 'cell-note', text: project.assigned_count === 1 ? 'person' : 'people' })
])
]);
if (!project.assigned_count) {
// Unclaimed work is the thing an administrator most needs to notice, so it
// is marked rather than left to be inferred from a zero.
assignedCell.querySelector('.count').after(
el('span', { class: 'status-pill status-pill--warning', text: 'Needs someone' })
);
}
return el('tr', null, [
el('td', null, [
el('div', { class: 'table-user' }, [
el('span', {
class: 'avatar avatar--small avatar--teal',
'aria-hidden': 'true',
text: api.initials(project.name, project.slug)
}),
el('span', null, [el('strong', { text: project.name || '—' })])
])
]),
el('td', null, [el('code', { class: 'slug', text: project.slug || '—' })]),
el('td', null, [projectStatusPill(project.status)]),
assignedCell,
el('td', null, [el('span', { class: 'cell-note', text: api.formatDateTime(project.created_at) })]),
el('td', null, [actions])
]);
}
function openProjectStatusDialog(project) {
projectStatusTarget = project;
setText('#project-status-target', project.name);
var select = document.getElementById('project-status-value');
if (select) {
api.clear(select);
// Only transitions the model allows, so the dialog cannot offer a move the
// server will refuse as invalid.
PROJECT_TRANSITIONS[project.status].forEach(function (status) {
select.appendChild(el('option', { value: status, text: api.label(status) }));
});
}
setValue('#project-status-reason', '');
setText('#project-status-message', '');
var modal = modals['project-status-modal'];
if (modal) {
modal.open();
}
}
/* Who the server will accept as an assignee: everyone in the caller's own
organization, and nobody else. The list is fetched rather than remembered so
it cannot drift from what the server would allow. */
function openProjectMembersDialog(project) {
projectMembersTarget = project;
setText('#project-members-target', project.name);
setText('#project-members-message', '');
var list = document.getElementById('project-members-list');
if (list) {
api.clear(list);
list.appendChild(el('p', { class: 'loading-note', text: 'Loading accounts…' }));
}
var modal = modals['project-members-modal'];
if (modal) {
modal.open();
}
api.listAccounts({ limit: 200 }).then(
function (data) {
if (!list || !projectMembersTarget || projectMembersTarget.id !== project.id) {
return;
}
var assigned = project.assigned_account_ids || [];
var accounts = (data && data.items) || [];
api.clear(list);
if (!accounts.length) {
list.appendChild(
el('p', {
class: 'cell-note',
text: 'This organization has no accounts yet, so there is nobody to assign.'
})
);
return;
}
accounts.forEach(function (account) {
var isAssigned = assigned.indexOf(String(account.id)) !== -1;
var row = el('div', { class: 'member-row' + (isAssigned ? ' member-row--assigned' : '') });
row.appendChild(
el('span', { class: 'avatar avatar--small', 'aria-hidden': 'true', text: api.initials(account.display_name, account.username) })
);
row.appendChild(
el('span', { class: 'member-row__name' }, [
el('strong', { text: account.display_name || account.username || account.email }),
el('small', { text: api.label(account.role) + ' · ' + account.email })
])
);
var toggle = el('button', {
class: 'button button--ghost',
type: 'button',
text: isAssigned ? 'Remove' : 'Assign'
});
toggle.addEventListener('click', function () {
api.disable(toggle, true);
// Named rather than looked up: `var call = a ? b : c; call.call(api)`
// reads as though either could be the wrong one, and hides which
// request is actually being made when this breaks.
var request = isAssigned
? api.unassignFromProject(project.id, account.id)
: api.assignToProject(project.id, account.id);
request.then(
function (result) {
api.showToast(
isAssigned ? 'Removed from the project.' : 'Assigned to the project.',
isAssigned ? 'info' : 'success'
);
// Take the server's answer rather than assuming it, so the row
// cannot claim an assignment the server refused.
project.assigned_account_ids = result.project.assigned_account_ids;
project.assigned_count = result.project.assigned_count;
if (result.changed) {
loadProjectsPage();
}
openProjectMembersDialog(project);
},
function (error) {
api.disable(toggle, false);
setText('#project-members-message', api.describeError(error));
}
);
});
row.appendChild(toggle);
list.appendChild(row);
});
},
function (error) {
if (list) {
api.clear(list);
list.appendChild(el('p', { class: 'form-message', text: api.describeError(error) }));
}
}
);
}
function openOrganizationEditor(organization) {
// Opens the modal as well as filling it. The row button calls this directly
// and is not a [data-open] control, so nothing else would display it: the
@@ -1226,6 +1482,99 @@
}
}
function setupProjectForms() {
var form = document.getElementById('project-form');
if (form) {
form.addEventListener('submit', function (event) {
event.preventDefault();
var nameInput = document.getElementById('project-name');
var slugInput = document.getElementById('project-slug');
var message = document.getElementById('project-message');
var submit = document.getElementById('project-submit');
var name = nameInput ? nameInput.value.trim() : '';
var slug = slugInput ? slugInput.value.trim() : '';
if (!name) {
setMessage(message, 'Enter a project name.', 'error');
return;
}
// No organization_id is sent. The server pins the project to the caller's
// own organization, and refusing a foreign one is a check we would rather
// not reimplement in the browser. An omitted slug is derived from the
// name; a supplied one is canonicalised server-side rather than validated
// here, so the two can never disagree about the rules.
var payload = { name: name };
if (slug) {
payload.slug = slug;
}
api.disable(submit, true);
api.createProject(payload).then(
function (data) {
api.disable(submit, false);
if (form) {
form.reset();
}
closeModal('project-modal');
api.showToast('Project “' + ((data && data.name) || name) + '” created.');
loadProjectsPage();
},
function (error) {
api.disable(submit, false);
setMessage(message, describeFailure(error, 'The project could not be created.'), 'error');
}
);
});
}
var statusForm = document.getElementById('project-status-form');
if (statusForm) {
statusForm.addEventListener('submit', function (event) {
event.preventDefault();
if (!projectStatusTarget) {
return;
}
var message = document.getElementById('project-status-message');
var submit = document.getElementById('project-status-submit');
var status = readValue('#project-status-value', '');
var reason = readValue('#project-status-reason', '');
if (!status) {
setMessage(message, 'Choose a status.', 'error');
return;
}
api.disable(submit, true);
api.setProjectStatus(projectStatusTarget.id, status, reason).then(
function (data) {
api.disable(submit, false);
projectStatusTarget = null;
closeModal('project-status-modal');
api.showToast('“' + ((data && data.name) || 'The project') + '” is now ' + api.label(status) + '.');
loadProjectsPage();
},
function (error) {
api.disable(submit, false);
setMessage(message, describeFailure(error, 'The status could not be changed.'), 'error');
}
);
});
}
var filters = document.getElementById('project-filters');
if (filters) {
filters.addEventListener('change', function () {
state.projects.status = readValue('#filter-project-status', '');
state.projects.offset = 0;
loadProjectsPage();
});
}
var reset = document.getElementById('filter-project-reset');
if (reset) {
reset.addEventListener('click', function () {
setValue('#filter-project-status', '');
state.projects = { status: '', limit: 200, offset: 0 };
loadProjectsPage();
});
}
}
function setupOrganizationForms() {
var form = document.getElementById('organization-form');
if (form) {
@@ -1527,6 +1876,11 @@
return node ? node.value : fallback;
}
/** Write text into a node, tolerating a missing one. */
function setText(selector, value) {
api.setText(typeof selector === 'string' ? document.querySelector(selector) : selector, value);
}
function setValue(selector, value) {
var node = query(selector);
if (node) {
@@ -1635,6 +1989,8 @@
loadAccounts();
} else if (name === 'organizations') {
loadOrganizationsPage();
} else if (name === 'projects') {
loadProjectsPage();
} else if (name === 'audit') {
loadAuditTargets();
loadAudit();
@@ -1726,6 +2082,7 @@
setupProvisionForm();
setupAccountActionForms();
setupOrganizationForms();
setupProjectForms();
api.setSessionLostHandler(function () {
api.forgetLocalState();

View File

@@ -422,6 +422,59 @@
return request('/api/uadmin/audit', { params: filters, signal: signal });
}
/* --------------------------------------------------------------- projects */
/**
* The signed-in caller's organization and its projects, in one response.
*
* Deliberately not `listProjects` plus `me` plus `getOrganization`. The server
* already knows which organization this session belongs to, and asking the
* browser to pass it back is how a stale or mistimed filter shows one tenant's
* work to another. There is no organization parameter here to get wrong.
*/
function workspace(signal) {
return request('/api/workspace', { signal: signal });
}
function listProjects(filters, signal) {
return request('/api/uadmin/projects', { params: filters, signal: signal });
}
function createProject(payload, signal) {
return request('/api/uadmin/projects', { method: 'POST', body: payload, signal: signal });
}
function setProjectStatus(projectId, status, reason, signal) {
return request('/api/uadmin/projects/' + encodeURIComponent(projectId) + '/status', {
method: 'PATCH',
body: { status: status, reason: reason === '' ? null : reason },
signal: signal
});
}
/**
* Assign an account to a project. A PUT because the request states a desired
* state: assigning someone who is already assigned succeeds and reports
* `changed: false`, so a retry after a dropped response is safe.
*/
function assignToProject(projectId, userId, signal) {
return request(
'/api/uadmin/projects/' + encodeURIComponent(projectId) + '/members/' + encodeURIComponent(userId),
{ method: 'PUT', signal: signal }
);
}
/**
* Remove an assignment. The project stays visible to the whole organization;
* only the work stops being anybody's.
*/
function unassignFromProject(projectId, userId, signal) {
return request(
'/api/uadmin/projects/' + encodeURIComponent(projectId) + '/members/' + encodeURIComponent(userId),
{ method: 'DELETE', signal: signal }
);
}
/* --------------------------------------------------------- DOM utilities */
function appendChild(node, child) {
@@ -804,6 +857,12 @@
updateOrganization: updateOrganization,
setOrganizationStatus: setOrganizationStatus,
listAudit: listAudit,
workspace: workspace,
listProjects: listProjects,
createProject: createProject,
setProjectStatus: setProjectStatus,
assignToProject: assignToProject,
unassignFromProject: unassignFromProject,
request: request,
el: el,
clear: clearNode,

View File

@@ -522,9 +522,7 @@
if (subtitle) {
subtitle.textContent =
'Signed in as ' + (user.username || 'this account') +
' with the ' + api.label(role).toLowerCase() +
' role. Project data is not available yet: the backend exposes account, ' +
'organization, and audit endpoints only.';
' with the ' + api.label(role).toLowerCase() + ' role.';
}
var pill = query('#workspace-session-pill');
if (pill) {
@@ -543,6 +541,217 @@
);
}
/* ------------------------------------------------------- the dashboard */
// The one response that fills the page. Rendered from `snapshot` and
// re-filtered from it locally, so switching between "all" and "assigned to
// me" can never show a mix of two fetches - and so a filter can never widen
// what the server decided this session may see.
var snapshot = null;
var filter = 'all';
var STATUS_TEXT = {
active: 'Active',
on_hold: 'On hold',
released: 'Released',
archived: 'Archived'
};
function statusPill(status) {
var tone =
status === 'active' ? 'success'
: status === 'on_hold' ? 'warning'
: status === 'released' ? 'info'
: 'muted';
return api.el('span', {
class: 'status-pill status-pill--' + tone,
text: STATUS_TEXT[status] || api.label(status)
});
}
function projectCard(project) {
var meta = [
api.el('span', { class: 'project-card__slug', text: '/' + project.slug }),
api.el('span', { text: project.assigned_count === 1 ? '1 person assigned' : project.assigned_count + ' assigned' })
];
if (project.updated_at) {
meta.push(api.el('span', { text: 'updated ' + api.formatDateTime(project.updated_at) }));
}
// Who is working on it is a fact about the project; whether that includes
// *you* is a fact about the viewer. Both are shown, and neither filters the
// row out. Visibility inside an organization is not per-assignment.
var side = [statusPill(project.status)];
if (project.assigned_to_me) {
side.push(api.el('span', { class: 'status-pill marker--mine', text: 'On your projects' }));
} else if (!project.assigned_count) {
side.push(api.el('span', { class: 'status-pill marker--unclaimed', text: 'Needs someone' }));
}
return api.el('article', { class: 'project-card project-card--' + project.status }, [
api.el('div', { class: 'project-card__main' }, [
api.el('span', { class: 'project-card__name', text: project.name }),
api.el('div', { class: 'project-card__meta' }, meta)
]),
api.el('div', { class: 'project-card__side' }, side)
]);
}
function setMetric(selector, value) {
var node = query(selector);
if (!node) {
return;
}
// A zero is a real measurement and is shown as 0. A dash means "we do not
// know yet", and is kept visually distinct so it is not read as zero.
node.textContent = value === null || value === undefined ? '—' : String(value);
if (value === null || value === undefined) {
node.dataset.empty = 'true';
} else {
delete node.dataset.empty;
}
}
function renderProjects() {
var list = query('#projects-list');
var empty = query('#projects-empty');
var summary = query('#projects-summary');
var heading = query('#projects-title');
var eyebrow = query('#projects-eyebrow');
if (!list || !snapshot) {
return;
}
var organization = snapshot.organization;
if (eyebrow) {
// The organization is the heading the work hangs under, so it is the
// heading - not the person's name, which is in the rail.
eyebrow.textContent = organization ? organization.name : 'Platform';
}
if (heading) {
heading.textContent = filter === 'mine' ? 'Your projects' : 'Work in this organization';
}
var all = snapshot.projects || [];
var mine = snapshot.assigned_to_me || [];
var visible = filter === 'mine' ? mine : all;
api.clearNode(list);
visible.forEach(function (project) {
list.appendChild(projectCard(project));
});
if (summary) {
summary.className = 'list-summary';
if (!organization) {
// A platform administrator belongs to no tenant, so there is no
// organization's work to show. Said plainly rather than as zero.
summary.textContent =
'You administer the platform rather than working in one organization. ' +
'Choose an organization from the control panel to see its projects.';
} else if (visible.length === 0) {
summary.textContent =
filter === 'mine'
? 'No projects are assigned to you in ' + organization.name + ' yet.'
: 'No projects in ' + organization.name + ' yet.';
} else if (filter === 'mine') {
summary.textContent =
'Showing ' + visible.length + ' of ' + all.length + ' project' +
(all.length === 1 ? '' : 's') + ' in ' + organization.name + '.';
} else {
summary.textContent =
'Showing all ' + all.length + ' project' + (all.length === 1 ? '' : 's') +
' in ' + organization.name + '. Everyone here sees every project; ' +
'being assigned says who is working on it.';
}
}
if (empty) {
var nothingAtAll = all.length === 0;
var nothingForMe = !nothingAtAll && visible.length === 0;
api.show(empty, nothingAtAll || nothingForMe);
var title = query('#projects-empty-title');
var body = query('#projects-empty-body');
if (title) {
title.textContent = nothingAtAll ? 'No projects yet' : 'Nothing assigned to you';
}
if (body) {
body.textContent = nothingAtAll
? 'This organization has no projects yet. An organization administrator can create the first one from the control panel.'
: filter === 'mine'
? 'No project in ' + organization.name + ' is assigned to you. You can still see all ' + all.length + ' of them below the filter.'
: '';
}
}
['#project-filter-all', '#project-filter-mine'].forEach(function (selector) {
var button = query(selector);
if (!button) {
return;
}
var active = button.id === (filter === 'mine' ? 'project-filter-mine' : 'project-filter-all');
button.className = active ? 'chip chip--active' : 'chip';
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
function renderMetrics() {
if (!snapshot) {
return;
}
var all = snapshot.projects || [];
var unclaimed = all.filter(function (project) { return !project.assigned_count; }).length;
var inProgress = all.filter(function (project) { return project.status === 'active'; }).length;
setMetric('#metric-projects', all.length);
setMetric('#metric-assigned', (snapshot.assigned_to_me || []).length);
setMetric('#metric-active', inProgress);
setMetric('#metric-unclaimed', unclaimed);
}
function bindFilter() {
[['#project-filter-all', 'all'], ['#project-filter-mine', 'mine']].forEach(function (entry) {
var button = query(entry[0]);
if (!button) {
return;
}
button.addEventListener('click', function () {
if (filter === entry[1]) {
return;
}
filter = entry[1];
renderProjects();
});
});
}
function loadWorkspace() {
return api.workspace().then(function (data) {
snapshot = data;
var organization = data.organization;
var title = query('#workspace-title');
if (title) {
// The organization names the page, because that is what the work
// belongs to. The account's own name stays in the rail.
title.textContent = organization ? organization.name : 'Platform';
}
var railRole = query('#rail-role');
if (railRole && organization) {
railRole.textContent = api.label((data.user && data.user.role) || 'member') + ' · ' + organization.name;
}
renderMetrics();
renderProjects();
}, function (error) {
var summary = query('#projects-summary');
if (summary && error && error.status !== 401 && error.code !== 'password_change_required') {
// Named as an error rather than left blank, so an empty list is never
// read as "this organization has no projects" when in fact nothing
// loaded.
summary.className = 'list-summary list-summary--error';
summary.textContent = 'Projects could not be loaded: ' + api.describeError(error);
}
});
}
function renderFailure(message) {
var subtitle = query('#workspace-subtitle');
if (subtitle) {
@@ -568,7 +777,16 @@
});
});
api.me().then(renderIdentity, function (error) {
bindFilter();
// The session identifies the caller; the workspace says what they can see.
// Both are needed and neither subsumes the other, so they are two requests -
// but the dashboard renders from the second one alone, and never from a
// mixture of the two.
api.me().then(function (session) {
renderIdentity(session);
return loadWorkspace();
}, function (error) {
// A 401 and a 403 password_change_required are both already being
// handled by the redirect handlers above. This covers anything else.
if (error && error.status === 401) {

View File

@@ -171,6 +171,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
.avatar { display: inline-grid; place-items: center; flex: 0 0 auto; width: 2rem; height: 2rem; color: #fff; background: #334155; border-radius: .6rem; font-size: .65rem; font-weight: 800; }
.avatar--admin { background: var(--admin-purple); }
.avatar--small { width: 1.85rem; height: 1.85rem; border-radius: .5rem; }
.avatar--teal { background: #0f766e; }
.avatar--violet { background: #7c3aed; }.avatar--blue { background: #2563eb; }.avatar--amber { background: #b45309; }.avatar--rose { background: #be185d; }
.admin-project-list { display: grid; gap: .55rem; }
.admin-project-row { display: flex; align-items: center; gap: .65rem; padding: .7rem; background: #101a30; border: 1px solid #26334f; border-radius: .5rem; }
@@ -214,6 +215,31 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
.workspace-hero { display: flex; align-items: end; justify-content: space-between; gap: 1.5rem; padding: 2.6rem 0 2rem; }.workspace-hero h1 { margin: 0 0 .45rem; font-size: clamp(2.2rem, 5vw, 4rem); letter-spacing: -.06em; }.hero-subtitle { max-width: 38rem; margin: 0; color: var(--workspace-muted); font-size: .9rem; line-height: 1.6; }.hero-actions { display: flex; gap: .6rem; flex-wrap: wrap; }
.pipeline-card, .workspace-panel { padding: 1.2rem; background: var(--workspace-panel); border: 1px solid var(--workspace-border); border-radius: .8rem; }.pipeline-card { margin-bottom: 1rem; }.section-heading { display: flex; align-items: start; justify-content: space-between; gap: 1rem; }.section-heading h2 { margin: 0; font-size: 1.05rem; letter-spacing: -.02em; }.section-heading .text-button { color: var(--workspace-teal); }
.pipeline-track { display: flex; align-items: center; margin-top: 1.5rem; overflow-x: auto; padding: .25rem 0 .4rem; }.pipeline-step { display: grid; justify-items: center; gap: .2rem; min-width: 5.5rem; text-align: center; }.pipeline-number { display: grid; place-items: center; width: 2rem; height: 2rem; color: #64748b; background: #1b2a40; border: 2px solid #3b4d69; border-radius: 50%; font-size: .72rem; font-weight: 800; }.pipeline-step strong { color: #cbd5e1; font-size: .72rem; }.pipeline-step small { color: #64748b; font-size: .62rem; }.pipeline-step--done .pipeline-number { color: #99f6e4; background: #115e59; border-color: #2dd4bf; }.pipeline-step--active .pipeline-number { color: #062a28; background: var(--workspace-teal); border-color: var(--workspace-teal); }.pipeline-step--active strong { color: #fff; }.pipeline-line { flex: 1; min-width: 1.2rem; height: 2px; margin: 0 .4rem 1.6rem; background: #2b3c58; }.pipeline-line--done { background: #2dd4bf; }
/* Project list on the member dashboard. The card is the unit of a project as
everybody in the organization sees it: visible to all, worked on by some. The
"assigned to you" marker is therefore a fact about the caller, not a filter on
the row - which is why it sits inside the row rather than replacing it. */
.project-list { display: grid; gap: .55rem; margin-top: .8rem; }
.list-summary { margin: .7rem 0 0; color: var(--workspace-muted); font-size: .7rem; }
.list-summary--error { color: #fca5a5; }
.project-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: .8rem; padding: .8rem .9rem; background: var(--workspace-panel-raised); border: 1px solid var(--workspace-border); border-left: 3px solid var(--workspace-border); border-radius: .6rem; }
.project-card--active { border-left-color: var(--workspace-teal); }
.project-card--on_hold { border-left-color: #fcd34d; }
.project-card--released { border-left-color: #93c5fd; }
.project-card--archived { border-left-color: #475569; opacity: .72; }
.project-card__main { display: grid; gap: .3rem; min-width: 0; }
.project-card__name { overflow: hidden; color: #e2e8f0; font-size: .85rem; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.project-card__meta { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; color: #64748b; font-size: .65rem; }
.project-card__slug { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.project-card__side { display: flex; align-items: center; gap: .45rem; }
.marker--mine { color: #99f6e4; background: #115e59; }
.marker--unclaimed { color: #fcd34d; background: #78350f; }
.chip { padding: .3rem .6rem; color: var(--workspace-muted); background: transparent; border: 1px solid var(--workspace-border); border-radius: 999px; font: inherit; font-size: .68rem; cursor: pointer; }
.chip:hover { color: var(--workspace-text); border-color: #3b5273; }
.chip--active { color: #0b1120; background: var(--workspace-teal); border-color: var(--workspace-teal); font-weight: 750; }
.chip:focus-visible { outline: 2px solid var(--workspace-teal); outline-offset: 2px; }
.metric-card strong[data-empty="true"] { color: #475569; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .8rem; margin-bottom: 1rem; }.metric-card { display: grid; gap: .3rem; padding: 1rem; background: var(--workspace-panel-raised); border: 1px solid var(--workspace-border); border-radius: .7rem; }.metric-label { color: var(--workspace-muted); font-size: .68rem; }.metric-card strong { font-size: 1.8rem; letter-spacing: -.05em; }.metric-note { color: #64748b; font-size: .65rem; }.metric-note--good { color: #6ee7b7; }.metric-note--warn { color: #fcd34d; }
.workspace-columns { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(18rem, .75fr); gap: 1rem; margin-bottom: 1rem; }.workspace-panel { min-width: 0; }.language-list { display: grid; gap: .2rem; margin-top: 1rem; }.language-row { display: grid; grid-template-columns: 2.2rem minmax(8rem, 1fr) minmax(5rem, .8fr) 2.5rem auto; align-items: center; gap: .7rem; padding: .65rem .2rem; border-top: 1px solid #1e2f48; }.language-code { display: grid; place-items: center; width: 2rem; height: 2rem; color: #bfdbfe; background: #1e3a5f; border-radius: .5rem; font-size: .62rem; font-weight: 800; }.language-code--source { color: #99f6e4; background: #115e59; }.language-name { display: grid; gap: .15rem; }.language-name strong { font-size: .76rem; }.language-name small { color: #64748b; font-size: .63rem; }.progress-track { height: .35rem; overflow: hidden; background: #243650; border-radius: 999px; }.progress-track span { display: block; height: 100%; background: linear-gradient(90deg, #14b8a6, #5eead4); border-radius: inherit; }.language-percent { color: #cbd5e1; font-size: .7rem; text-align: right; }
.cast-summary { display: flex; align-items: center; gap: 1rem; margin: 1.1rem 0 1.2rem; }.cast-summary p { margin: .35rem 0 0; color: #64748b; font-size: .68rem; line-height: 1.5; }.cast-ring { display: grid; place-items: center; width: 4.6rem; height: 4.6rem; border: 5px solid #1f4152; border-top-color: var(--workspace-teal); border-right-color: var(--workspace-teal); border-radius: 50%; }.cast-ring strong { font-size: .8rem; }.cast-ring span { color: #64748b; font-size: .55rem; }.cast-avatars { display: flex; padding-left: .3rem; }.cast-avatar { display: grid; place-items: center; width: 2rem; height: 2rem; margin-left: -.3rem; color: #e0e7ff; border: 2px solid var(--workspace-panel); border-radius: 50%; font-size: .58rem; font-weight: 800; }.cast-avatar--one { background: #7c3aed; }.cast-avatar--two { background: #2563eb; }.cast-avatar--three { background: #b45309; }.cast-avatar--four { background: #be185d; }.cast-avatar--more { color: #94a3b8; background: #243650; }
@@ -300,6 +326,16 @@ html.modal-open { overflow: hidden; }
.pagination__buttons .button { min-height: 2.1rem; padding: .35rem .7rem; font-size: .7rem; }
/* Empty states, used by tables and by the whole workspace page */
/* Assigning an account to a project. The assigned state is marked on the row as
well as in the button label, so the list reads as "who is on this" at a glance
rather than requiring each button to be read. */
.member-list { display: grid; gap: .3rem; max-height: 22rem; overflow-y: auto; margin: .8rem 0; }
.member-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: .7rem; padding: .5rem .6rem; border: 1px solid #1e2f48; border-radius: .5rem; }
.member-row--assigned { border-color: #115e59; background: rgba(15, 118, 110, .14); }
.member-row__name { display: grid; gap: .1rem; min-width: 0; }
.member-row__name strong { overflow: hidden; font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
.member-row__name small { overflow: hidden; color: #64748b; font-size: .63rem; text-overflow: ellipsis; white-space: nowrap; }
.empty-state { display: grid; justify-items: start; gap: .4rem; margin-top: .9rem; padding: 1.1rem; background: #101a30; border: 1px dashed #2c3c5c; border-radius: .6rem; }
.empty-state__icon { display: grid; place-items: center; width: 2rem; height: 2rem; color: #5eead4; background: #0f2a35; border-radius: .5rem; font-size: .9rem; }
.empty-state__title { margin: 0; color: #e2e8f0; font-size: .8rem; font-weight: 750; }

View File

@@ -32,6 +32,7 @@
<a class="admin-nav-link admin-nav-link--active" href="#dashboard" data-view-link="dashboard"><span aria-hidden="true">▦</span>Dashboard</a>
<a class="admin-nav-link" href="#accounts" data-view-link="accounts"><span aria-hidden="true">♙</span>Accounts</a>
<a class="admin-nav-link" href="#organizations" data-view-link="organizations"><span aria-hidden="true">◈</span>Organizations</a>
<a class="admin-nav-link" href="#projects" data-view-link="projects"><span aria-hidden="true">◇</span>Projects</a>
<a class="admin-nav-link" href="#audit" data-view-link="audit"><span aria-hidden="true">≡</span>Audit log</a>
<a class="admin-nav-link" href="../settings.html"><span aria-hidden="true">⚿</span>Your account</a>
</nav>
@@ -215,6 +216,59 @@
</section>
</section>
<!-- Projects ------------------------------------------------------ -->
<section class="admin-view" id="view-projects" data-view="projects" hidden>
<div class="admin-heading">
<div>
<p class="eyebrow">Work</p>
<h1>Projects</h1>
<p>
Units of work inside your organization. Everyone in the organization sees every
project; assigning an account says who is <em>working</em> on one, not who may
look at it. So a project nobody is on still appears, which is how you find work
that needs a writer.
</p>
</div>
<button class="button button--primary" type="button" id="open-project-modal" data-open="project-modal">+ New project</button>
</div>
<section class="admin-card">
<form class="toolbar" id="project-filters" novalidate>
<div class="filter-field">
<label for="filter-project-status">Status</label>
<select id="filter-project-status" name="status">
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="on_hold">On hold</option>
<option value="released">Released</option>
<option value="archived">Archived</option>
</select>
</div>
<div class="toolbar-actions">
<button class="button button--ghost" type="button" id="filter-project-reset">Reset</button>
</div>
</form>
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="visually-hidden">Projects in your administrative scope</caption>
<thead>
<tr>
<th scope="col">Project</th>
<th scope="col">Slug</th>
<th scope="col">Status</th>
<th scope="col">Working on it</th>
<th scope="col">Created</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="projects-body"></tbody>
</table>
</div>
<div class="pagination" id="projects-pagination"></div>
</section>
</section>
<!-- Audit log ---------------------------------------------------- -->
<section class="admin-view" id="view-audit" data-view="audit" hidden>
<div class="admin-heading">
@@ -400,6 +454,86 @@
</section>
</div>
<!-- Project ------------------------------------------------------------ -->
<div class="modal-backdrop" id="project-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="project-modal-title">
<div class="modal-heading">
<div><p class="eyebrow">Work</p><h2 id="project-modal-title">New project</h2></div>
<button class="modal-close" type="button" data-close="project-modal" aria-label="Close">×</button>
</div>
<p class="modal-notice">Created inside your organization. Everyone in the organization will see it straight away &mdash; being assigned says who is working on it, not who may look.</p>
<form id="project-form" class="stack-form" novalidate>
<div class="form-field">
<label for="project-name">Name</label>
<input id="project-name" name="name" type="text" autocomplete="off" maxlength="120" required>
</div>
<div class="form-field">
<label for="project-slug">Slug <span class="field-optional">optional, derived from the name</span></label>
<input id="project-slug" name="slug" type="text" autocomplete="off" maxlength="63" placeholder="derived from the name">
</div>
<div id="project-message" class="form-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button class="button button--ghost" type="button" data-close="project-modal">Cancel</button>
<button class="button button--primary" type="submit" id="project-submit">Create project</button>
</div>
</form>
</section>
</div>
<!-- Project status ---------------------------------------------------- -->
<div class="modal-backdrop" id="project-status-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="project-status-title">
<div class="modal-heading">
<div><p class="eyebrow">Work</p><h2 id="project-status-title">Change project status</h2></div>
<button class="modal-close" type="button" data-close="project-status-modal" aria-label="Close">×</button>
</div>
<p class="modal-notice">
Only the moves that make sense from where the project is now are offered.
<strong>Archived is terminal</strong> &mdash; an archived project cannot be reopened by anyone,
including a platform administrator.
</p>
<form id="project-status-form" class="stack-form" novalidate>
<div class="form-field">
<span class="field-label">Project</span>
<p class="readonly-value" id="project-status-target">—</p>
</div>
<div class="form-field">
<label for="project-status-value">New status</label>
<select id="project-status-value" name="status"></select>
</div>
<div class="form-field">
<label for="project-status-reason">Reason <span class="field-optional">optional, recorded in the audit log</span></label>
<input id="project-status-reason" name="reason" type="text" maxlength="200" autocomplete="off">
</div>
<div id="project-status-message" class="form-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button class="button button--ghost" type="button" data-close="project-status-modal">Cancel</button>
<button class="button button--primary" type="submit" id="project-status-submit">Change status</button>
</div>
</form>
</section>
</div>
<!-- Project members ---------------------------------------------------- -->
<div class="modal-backdrop" id="project-members-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="project-members-title">
<div class="modal-heading">
<div><p class="eyebrow">Work</p><h2 id="project-members-title">Who is working on it</h2></div>
<button class="modal-close" type="button" data-close="project-members-modal" aria-label="Close">×</button>
</div>
<p class="modal-notice" id="project-members-target">—</p>
<p class="modal-notice">
Assigning says who is <em>working</em> on the project. It does not change who can see it:
everyone in the organization sees every project, assigned or not.
</p>
<div id="project-members-list" class="member-list"></div>
<div id="project-members-message" class="form-message" role="status" aria-live="polite"></div>
<div class="modal-actions">
<button class="button button--primary" type="button" data-close="project-members-modal">Done</button>
</div>
</section>
</div>
<!-- Organization status ----------------------------------------------- -->
<div class="modal-backdrop" id="organization-status-modal" hidden>
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="organization-status-title">

View File

@@ -63,37 +63,52 @@
</div>
</section>
<section class="pipeline-card" aria-labelledby="release-title">
<section class="pipeline-card" aria-labelledby="projects-title">
<div class="section-heading">
<div><p class="eyebrow eyebrow--teal">Production pipeline</p><h2 id="release-title">Release readiness</h2></div>
<div>
<p class="eyebrow eyebrow--teal" id="projects-eyebrow">Projects</p>
<h2 id="projects-title">Work in this organization</h2>
</div>
<div class="section-heading__actions" role="group" aria-label="Filter projects">
<button class="chip chip--active" type="button" id="project-filter-all" aria-pressed="true">All</button>
<button class="chip" type="button" id="project-filter-mine" aria-pressed="false">Assigned to me</button>
</div>
</div>
<div class="empty-state">
<span class="empty-state__icon" aria-hidden="true">✓</span>
<p class="empty-state__title">No release data</p>
<p class="empty-state__body">Release stages are derived from project, analysis, voice, and audio state. None of those endpoints exist in the backend yet, so no stage progress is reported.</p>
<!--
One list, rendered from one response. The filter above narrows what is
already in the page rather than refetching, so switching cannot show a
half-updated mix of two organizations' projects, and the count in the
heading always describes the same list the filter applies to.
-->
<p class="list-summary" id="projects-summary" role="status" aria-live="polite">Loading projects…</p>
<div id="projects-list" class="project-list"></div>
<div class="empty-state" id="projects-empty" hidden>
<span class="empty-state__icon" aria-hidden="true">◇</span>
<p class="empty-state__title" id="projects-empty-title">No projects yet</p>
<p class="empty-state__body" id="projects-empty-body">An organization administrator can create the first one from the control panel.</p>
</div>
</section>
<section class="metric-grid" aria-label="Project statistics">
<article class="metric-card">
<span class="metric-label">Target languages</span>
<strong aria-hidden="true">—</strong>
<span class="metric-note metric-note--warn">No project backend yet</span>
<span class="metric-label">Projects</span>
<strong id="metric-projects" aria-hidden="true">—</strong>
<span class="metric-note" id="metric-projects-note">In this organization</span>
</article>
<article class="metric-card">
<span class="metric-label">Approved speakers</span>
<strong aria-hidden="true">—</strong>
<span class="metric-note">No project backend yet</span>
<span class="metric-label">Assigned to you</span>
<strong id="metric-assigned" aria-hidden="true">—</strong>
<span class="metric-note" id="metric-assigned-note">Projects you are working on</span>
</article>
<article class="metric-card">
<span class="metric-label">Voice cast</span>
<strong aria-hidden="true">—</strong>
<span class="metric-note">No project backend yet</span>
<span class="metric-label">In progress</span>
<strong id="metric-active" aria-hidden="true">—</strong>
<span class="metric-note">Active and not on hold</span>
</article>
<article class="metric-card">
<span class="metric-label">Open work items</span>
<strong aria-hidden="true">—</strong>
<span class="metric-note">No project backend yet</span>
<span class="metric-label">Needs a writer</span>
<strong id="metric-unclaimed" aria-hidden="true">—</strong>
<span class="metric-note metric-note--warn" id="metric-unclaimed-note">Nobody assigned yet</span>
</article>
</section>