"""Organisations - the tenant boundary itself.
A worked example of the core's contract, and the shortest one in the codebase:
every function takes `session` first and `principal` second, checks a scope
before it does anything, raises a domain error rather than an HTTP status, and
returns model objects rather than JSON.
"""
import re
from db.db_declarations import Membership, Organization, User
from . import auth as auth_core
from .context import Conflict, Invalid, NotFound, Principal, Scope
_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$")
def create_org(session, principal: Principal, name: str, slug: str) -> Organization:
"""Create a tenant. A system caller only: an organisation is created by the
bootstrap command or by a sign-up flow you write, not by an ordinary
signed-in user acting on some other tenant's behalf."""
principal.require(Scope.ADMIN)
slug = (slug or "").strip().lower()
if not _SLUG.match(slug):
raise Invalid("A slug is 3-60 characters of lowercase letters, digits and "
"hyphens, and cannot start or end with a hyphen.")
if session.query(Organization).filter(Organization.slug == slug).one_or_none():
raise Conflict(f"The slug {slug!r} is taken.")
org = Organization(name=(name or slug).strip(), slug=slug)
session.add(org)
session.commit()
return org
def get_org(session, principal: Principal, org_id: int) -> Organization:
"""One organisation, if this caller may see it.
NotFound rather than Forbidden when it is another tenant's: saying "that
exists but is not yours" confirms the existence of a customer to someone who
should not know they have one.
"""
principal.require(Scope.READ)
if not principal.spans_orgs and org_id != principal.org_id:
raise NotFound("No such organisation.")
org = session.get(Organization, org_id)
if org is None:
raise NotFound("No such organisation.")
return org
def _slugify(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", (name or "").lower()).strip("-")[:52]
def _unique_slug(session, *candidates: str) -> str:
"""A free slug, from the first candidate that yields one.
Needed because the paths that create a tenant without asking for one -
Google sign-in, any self-service sign-up you add - have a person's display
name and nothing else. A chain rather than a single value because a display
name is not required to contain a single slug-able character: "!!!" is
perfectly truthy and slugifies to the empty string, so testing the name for
truthiness picks it and then falls off the end.
Suffixed rather than randomised on collision, so the second "Acme" is
`acme-2` - still something a human recognises.
"""
base = next((s for s in (_slugify(c) for c in candidates) if s), "") or "org"
# create_org's validator wants at least three characters; a two-letter name
# is a real one ("Jo"), so pad it rather than discard it.
if len(base) < 3:
base = f"{base}-org"
slug, n = base, 1
while session.query(Organization).filter(Organization.slug == slug).one_or_none():
n += 1
slug = f"{base}-{n}"
return slug
def provision(session, user: User, org_name: str | None = None) -> Organization:
"""Give a brand-new account a tenant of its own, with that account as owner.
The path every "signed up just now" flow ends at. A system principal because
there is no existing tenant for the caller to be scoped to yet - which is the
whole situation this function exists for.
Naming is decided here rather than by the caller because everything needed is
already on `user`: the display name if it gives a usable slug, the local part
of the address otherwise.
"""
local = user.email.split("@")[0]
org = create_org(session, Principal.system(),
name=(org_name or "").strip() or local,
slug=_unique_slug(session, org_name, local))
auth_core.add_membership(session, user, org, "owner")
return org
def orgs_for_user(session, user_id: int) -> list[Organization]:
"""Every organisation a user belongs to - what the org switcher renders.
Takes a user_id rather than a Principal because it answers a question about
an account rather than about a tenant: the caller is asking which tenants
they may enter, so it cannot be scoped to the one they are in.
"""
return (session.query(Organization)
.join(Membership, Membership.org_id == Organization.id)
.filter(Membership.user_id == user_id)
.order_by(Organization.name)
.all())