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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
73
umbratrans_web/routes_workspace.py
Normal file
73
umbratrans_web/routes_workspace.py
Normal 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),
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
154
umbratrans_web/tenancy.py
Normal 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"]
|
||||
@@ -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."""
|
||||
|
||||
|
||||
432
umbratrans_web/tests/test_api_tenancy.py
Normal file
432
umbratrans_web/tests/test_api_tenancy.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
432
umbratrans_web/tests/test_tenancy.py
Normal file
432
umbratrans_web/tests/test_tenancy.py
Normal 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
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 — 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> — 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user