A portal session names a person, not whoever enrolled
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Remote access used to collapse every portal session onto the account that performed the enrolment. That was the only thing it could do while nothing here knew who was at the other end, and it is why letting a second person in meant handing them the first one's account. `user.portal_sub` is where a portal identity meets a local one: set for the enrolling superuser at enrolment, and for each person a superuser admits afterwards through Settings -> Remote access -> Add remote user. The code they type comes from the newcomer's own portal account, and it is redeemed against the hub with this installation's tunnel credential rather than with a portal session, so being let in is not itself the power to let others in. The account created is never a superuser, which closes the same door from this side. A proxy token now resolves through that mapping and nowhere else. An identity nobody mapped resolves to no user rather than falling back on the enroller, so deleting the local row under Admin -> Users is the whole of the revocation: it bites on a credential already in flight, and it does not wait on the portal being reachable to be told. Telling the portal is best effort for exactly that reason. The cost is stated where it lands, in DEPLOY.md: an installation enrolled before this has no mapping, so its owner reconnects once with a fresh code. Panels and the health summary still act as the enrolling account - neither of them is a person, and neither gained a way to name one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""Map local accounts to the portal identities they stand for
|
||||
|
||||
A portal session used to act as whoever enrolled this installation. It now
|
||||
names a person, and this column is where that name meets a local account: set
|
||||
for the enrolling superuser at enrolment, and for every remote user a superuser
|
||||
admits afterwards. Unique, because two local accounts standing for the same
|
||||
portal identity would make the lookup a coin toss.
|
||||
|
||||
Revision ID: c4e8b2170f93
|
||||
Revises: a3f1c07b52d9
|
||||
Create Date: 2026-08-21 10:42:07.512004
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c4e8b2170f93'
|
||||
down_revision = 'a3f1c07b52d9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
'user',
|
||||
sa.Column('portal_sub', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(op.f('ix_user_portal_sub'), 'user', ['portal_sub'], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index(op.f('ix_user_portal_sub'), table_name='user')
|
||||
op.drop_column('user', 'portal_sub')
|
||||
+24
-6
@@ -6,7 +6,7 @@ from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.cloud import config as cloud_config
|
||||
from app.core import security
|
||||
@@ -98,9 +98,11 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
|
||||
|
||||
The last branch is the seam a hosted deployment widens: a portal this
|
||||
installation was enrolled with signs tokens with a key pinned at
|
||||
enrolment, and they resolve to the local account that performed it. With
|
||||
no enrolment the branch raises immediately, so an offline installation
|
||||
pays nothing for the possibility. A screen that paired through that portal
|
||||
enrolment, and they name the portal account holding them, which resolves
|
||||
to whichever local account was mapped to it — the superuser who enrolled,
|
||||
or a remote user one of them admitted since. With no enrolment the branch
|
||||
raises immediately, so an offline installation pays nothing for the
|
||||
possibility. A screen that paired through that portal
|
||||
arrives there too, naming a panel — which is why the gate below is applied
|
||||
to whichever branch produced the claims rather than to one of them: where a
|
||||
panel credential was minted is not what decides what it may read.
|
||||
@@ -142,6 +144,22 @@ def _gate_panel(payload: dict[str, Any], request: Request | None) -> dict[str, A
|
||||
return payload
|
||||
|
||||
|
||||
def _user_for(session: Session, token_data: TokenPayload) -> User | None:
|
||||
"""The local account a payload names, by id or by portal identity.
|
||||
|
||||
A token the portal minted names a person on the portal, not a user here, so
|
||||
the mapping a superuser made when they admitted them is what turns one into
|
||||
the other. No mapping, no user — the caller answers that the same way it
|
||||
answers a token naming a deleted account, which is what makes deleting the
|
||||
local user the whole of the revocation.
|
||||
"""
|
||||
if token_data.portal_sub:
|
||||
return session.exec(
|
||||
select(User).where(User.portal_sub == token_data.portal_sub)
|
||||
).first()
|
||||
return session.get(User, token_data.sub) if token_data.sub else None
|
||||
|
||||
|
||||
def user_from_token(
|
||||
session: Session, token: str, request: Request | None = None
|
||||
) -> User | None:
|
||||
@@ -156,7 +174,7 @@ def user_from_token(
|
||||
token_data = TokenPayload(**decode_token(token, request))
|
||||
except (InvalidTokenError, ValidationError):
|
||||
return None
|
||||
user = session.get(User, token_data.sub)
|
||||
user = _user_for(session, token_data)
|
||||
if user is None or not user.is_active:
|
||||
return None
|
||||
return user
|
||||
@@ -178,7 +196,7 @@ def get_current_user(request: Request, session: SessionDep, token: TokenDep) ->
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
user = session.get(User, token_data.sub)
|
||||
user = _user_for(session, token_data)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
||||
@@ -3,22 +3,36 @@
|
||||
Entirely optional, and superuser-only to change: enrolling grants a remote
|
||||
party the rights of the account that performed it, which is not a decision an
|
||||
ordinary user of this installation gets to make on everyone else's behalf.
|
||||
|
||||
Admitting further portal accounts is the same decision made again, so it is
|
||||
guarded the same way. A person let in this way gets an ordinary local account
|
||||
of their own — never a superuser, so they cannot admit anyone else, and the
|
||||
chain stops with whoever a superuser here typed a code for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import select
|
||||
|
||||
from app.api.deps import CurrentUser, get_current_active_superuser, get_current_user
|
||||
from app import crud
|
||||
from app.api.deps import (
|
||||
CurrentUser,
|
||||
SessionDep,
|
||||
get_current_active_superuser,
|
||||
get_current_user,
|
||||
)
|
||||
from app.cloud import config as cloud_config
|
||||
from app.models import Message
|
||||
from app.core.security import get_password_hash
|
||||
from app.models import Message, User, UserPublic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,12 +75,14 @@ def read_status(request: Request) -> dict[str, Any]:
|
||||
response_model=Message,
|
||||
)
|
||||
async def enroll(
|
||||
request: Request, current_user: CurrentUser, body: EnrollBody
|
||||
request: Request, session: SessionDep, current_user: CurrentUser, body: EnrollBody
|
||||
) -> Message:
|
||||
"""Redeem a claim code and start dialling the portal.
|
||||
|
||||
The account performing this is recorded as the one every portal session
|
||||
will act as. There is no way to widen that later from the portal side.
|
||||
The account performing this is mapped to the portal account that owns the
|
||||
installation, so the owner's portal sessions arrive here as them. Widening
|
||||
that to anyone else is a local decision made one person at a time, below —
|
||||
never something the portal can do from its side.
|
||||
"""
|
||||
if cloud_config.exists():
|
||||
raise HTTPException(
|
||||
@@ -96,6 +112,16 @@ async def enroll(
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
owner_id = data.get("owner_id")
|
||||
if not owner_id:
|
||||
# A portal older than remote users does not say who owns the
|
||||
# installation, and without that the enrolling account cannot be mapped
|
||||
# to anyone — which would leave the portal connected but refused here.
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="That portal is too old for this installation: it did not "
|
||||
"say which account owns the installation",
|
||||
)
|
||||
config = cloud_config.CloudConfig(
|
||||
portal_url=base,
|
||||
ws_url=data["ws_url"],
|
||||
@@ -110,10 +136,129 @@ async def enroll(
|
||||
portal_account=current_user.email,
|
||||
)
|
||||
cloud_config.save(config)
|
||||
|
||||
owner_id = str(owner_id)
|
||||
# Re-enrolling from a different local account moves the mapping rather than
|
||||
# leaving two accounts claiming the same portal identity, which the unique
|
||||
# index would refuse and the lookup could not choose between anyway.
|
||||
for other in session.exec(
|
||||
select(User).where(User.portal_sub == owner_id, User.id != current_user.id)
|
||||
):
|
||||
other.portal_sub = None
|
||||
session.add(other)
|
||||
current_user.portal_sub = owner_id
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
_start_connector(request.app)
|
||||
return Message(message="Connected to the portal")
|
||||
|
||||
|
||||
class RemoteUserBody(BaseModel):
|
||||
#: The code the person wanting in minted on their portal account.
|
||||
code: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/users",
|
||||
dependencies=[Depends(get_current_active_superuser)],
|
||||
response_model=UserPublic,
|
||||
)
|
||||
def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
|
||||
"""Admit a portal account, and give it a local user of its own.
|
||||
|
||||
The point of the local user is that it is ordinary: a remote person shows
|
||||
up in Admin → Users like everyone else, holds no superuser flag, and is
|
||||
removed by deleting them there. They have no password because they never
|
||||
sign in here — the portal vouches for them, and this row is what says which
|
||||
of our accounts that vouching amounts to.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This installation is not connected to a portal",
|
||||
)
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
json={"code": body.code.strip()},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502, detail=f"Could not reach the portal: {exc}"
|
||||
) from exc
|
||||
if response.status_code == 404:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="That code is unknown or has expired"
|
||||
)
|
||||
if response.status_code == 409:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="That code belongs to this installation's owner"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"The portal refused the code ({response.status_code})",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
portal_sub = str(data["user_id"])
|
||||
email = str(data["email"])
|
||||
existing = session.exec(select(User).where(User.portal_sub == portal_sub)).first()
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail=f"{existing.email} already has access"
|
||||
)
|
||||
if crud.get_user_by_email(session=session, email=email) is not None:
|
||||
# Never quietly hand an existing local account — possibly a superuser's
|
||||
# — to whoever holds that address on the portal.
|
||||
raise HTTPException(
|
||||
status_code=409, detail="A local user with this email already exists"
|
||||
)
|
||||
user = User(
|
||||
email=email,
|
||||
# Unusable by construction: this account is reached through the portal
|
||||
# or not at all. Password recovery would issue one, which is a gap
|
||||
# worth closing the day local sign-in for these accounts is wanted.
|
||||
hashed_password=get_password_hash(secrets.token_urlsafe(32)),
|
||||
is_superuser=False,
|
||||
is_active=True,
|
||||
portal_sub=portal_sub,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
def forget_remote_user(portal_sub: str) -> None:
|
||||
"""Tell the portal a remote user is gone from here.
|
||||
|
||||
Best effort on purpose: the local account is what grants access, so it is
|
||||
already over by the time this runs. A portal that cannot be reached keeps a
|
||||
row that opens nothing — the installation refuses the session either way.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
return
|
||||
try:
|
||||
response = httpx.delete(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/{portal_sub}",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
timeout=15.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Could not drop %s at the portal: %s", portal_sub, exc)
|
||||
return
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"The portal refused to drop %s (%s)", portal_sub, response.status_code
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"", dependencies=[Depends(get_current_active_superuser)], response_model=Message
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.api.deps import (
|
||||
SessionDep,
|
||||
get_current_active_superuser,
|
||||
)
|
||||
from app.api.routes.cloud import forget_remote_user
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models import (
|
||||
@@ -136,8 +137,11 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||
)
|
||||
portal_sub = current_user.portal_sub
|
||||
session.delete(current_user)
|
||||
session.commit()
|
||||
if portal_sub:
|
||||
forget_remote_user(portal_sub)
|
||||
return Message(message="User deleted successfully")
|
||||
|
||||
|
||||
@@ -223,6 +227,11 @@ def delete_user(
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Super users are not allowed to delete themselves"
|
||||
)
|
||||
portal_sub = user.portal_sub
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
# The local row is the access; dropping the portal's is tidying up, and it
|
||||
# happens after the thing that actually revokes has already happened.
|
||||
if portal_sub:
|
||||
forget_remote_user(portal_sub)
|
||||
return Message(message="User deleted successfully")
|
||||
|
||||
@@ -46,9 +46,11 @@ class CloudConfig:
|
||||
token: str
|
||||
issuer: str
|
||||
jwks: dict[str, Any]
|
||||
#: The local account every portal session acts as. Recorded at enrolment
|
||||
#: from whoever performed it, so remote access can never exceed the rights
|
||||
#: of the person who granted it.
|
||||
#: The local account this installation acts as on its own behalf: what the
|
||||
#: health summary is collected as, and what a screen paired through the
|
||||
#: portal borrows for want of a person. Recorded at enrolment from whoever
|
||||
#: performed it. Portal *sessions* no longer come through here — they name
|
||||
#: a person, and are resolved to the local account mapped to them.
|
||||
local_user_id: str
|
||||
enrolled_at: str
|
||||
portal_account: str | None = None
|
||||
@@ -145,6 +147,12 @@ def decode_portal_token(token: str) -> dict[str, Any]:
|
||||
return {"sub": config.local_user_id, "panel": panel}
|
||||
if scope != "proxy":
|
||||
raise InvalidTokenError("not a proxy token")
|
||||
# Every portal session acts as the enrolling local user. Who they are on
|
||||
# the portal is kept for the audit trail, not for authorization.
|
||||
return {"sub": config.local_user_id, "portal_sub": claims.get("sub")}
|
||||
# A portal session names the person holding it, and that name is the whole
|
||||
# of their identity here: the caller resolves it to the local account it
|
||||
# was mapped to, and a portal identity nobody mapped resolves to nothing.
|
||||
# Deliberately no local account by default — the failure of a mapping must
|
||||
# be a refusal, not a fallback onto whoever enrolled.
|
||||
portal_sub = str(claims.get("sub") or "")
|
||||
if not portal_sub:
|
||||
raise InvalidTokenError("a proxy token must name its user")
|
||||
return {"portal_sub": portal_sub}
|
||||
|
||||
@@ -50,6 +50,11 @@ class UpdatePassword(SQLModel):
|
||||
class User(UserBase, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
hashed_password: str
|
||||
#: The portal account this local user stands for, if any. Set for the
|
||||
#: superuser who enrolled this installation and for every remote user one
|
||||
#: of them admitted; None for a purely local account, which is what an
|
||||
#: installation nobody enrolled has only.
|
||||
portal_sub: str | None = Field(default=None, max_length=64, unique=True, index=True)
|
||||
created_at: datetime | None = Field(
|
||||
default_factory=get_datetime_utc,
|
||||
sa_type=DateTime(timezone=True), # type: ignore
|
||||
@@ -59,6 +64,7 @@ class User(UserBase, table=True):
|
||||
# Properties to return via API, id is always required
|
||||
class UserPublic(UserBase):
|
||||
id: uuid.UUID
|
||||
portal_sub: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
@@ -81,6 +87,11 @@ class Token(SQLModel):
|
||||
# Contents of JWT token
|
||||
class TokenPayload(SQLModel):
|
||||
sub: str | None = None
|
||||
#: Set instead of ``sub`` by a token the portal minted for a person: it
|
||||
#: names them on the portal, and the local account it stands for is looked
|
||||
#: up from it. A portal identity nobody here was mapped to resolves to no
|
||||
#: user at all, which is the refusal.
|
||||
portal_sub: str | None = None
|
||||
|
||||
|
||||
class NewPassword(SQLModel):
|
||||
|
||||
@@ -107,6 +107,14 @@ def enrolled(
|
||||
portal_account=settings.FIRST_SUPERUSER,
|
||||
)
|
||||
)
|
||||
# Enrolment also maps the enrolling account to the portal identity that
|
||||
# owns the installation; without it a portal session resolves to nobody.
|
||||
local_user.portal_sub = "portal-user-1"
|
||||
db.add(local_user)
|
||||
db.commit()
|
||||
yield local_user
|
||||
local_user.portal_sub = None
|
||||
db.add(local_user)
|
||||
db.commit()
|
||||
cloud_config.delete()
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
+116
-7
@@ -1,14 +1,16 @@
|
||||
"""Remote access: off by default, and scoped to the account that enabled it.
|
||||
"""Remote access: off by default, and only ever as much as somebody granted.
|
||||
|
||||
The property worth pinning down is the one everything else rests on — a
|
||||
portal's token is worth nothing here until somebody at this installation
|
||||
enrolled it, and even then it grants exactly the rights of the account that
|
||||
did.
|
||||
enrolled it, and even then it grants exactly the rights of the local account
|
||||
the portal identity holding it was mapped to. An identity nobody mapped gets
|
||||
nothing, which is what makes deleting that local account a revocation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
@@ -37,14 +39,18 @@ def test_portal_token_is_refused_when_not_enrolled(
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
|
||||
def test_portal_token_resolves_to_the_enrolling_user(
|
||||
def test_portal_token_resolves_by_portal_identity(
|
||||
enrolled: User, portal_key: rsa.RSAPrivateKey, db: Session
|
||||
) -> None:
|
||||
"""The token names a person on the portal; the mapping names them here."""
|
||||
claims = decode_token(portal_token(portal_key))
|
||||
assert claims["sub"] == str(enrolled.id)
|
||||
# Who they are on the portal is carried for the record, not for rights.
|
||||
# No local account of its own: the payload carries who they are on the
|
||||
# portal and nothing else, so an unmapped identity cannot fall back onto
|
||||
# whoever enrolled.
|
||||
assert "sub" not in claims
|
||||
assert claims["portal_sub"] == "portal-user-1"
|
||||
assert user_from_token(db, portal_token(portal_key)) == enrolled
|
||||
assert user_from_token(db, portal_token(portal_key, subject="nobody")) is None
|
||||
|
||||
|
||||
def test_token_for_another_installation_is_refused(
|
||||
@@ -77,7 +83,7 @@ def test_disconnecting_ends_remote_access(
|
||||
enrolled: User, portal_key: rsa.RSAPrivateKey
|
||||
) -> None:
|
||||
"""Deleting the config is the whole of the local revocation."""
|
||||
assert decode_token(portal_token(portal_key))["sub"] == str(enrolled.id)
|
||||
assert decode_token(portal_token(portal_key))["portal_sub"] == enrolled.portal_sub
|
||||
cloud_config.delete()
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(portal_token(portal_key))
|
||||
@@ -172,3 +178,106 @@ def test_a_panel_token_naming_no_panel_is_refused(
|
||||
"""It would otherwise fall through to the account it borrows."""
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(portal_token(portal_key, subject="", scope="panel"))
|
||||
|
||||
|
||||
def test_adding_a_remote_user_maps_and_revokes(
|
||||
client: TestClient,
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
superuser_token_headers: dict[str, str],
|
||||
db: Session,
|
||||
) -> None:
|
||||
"""Admitting somebody makes an ordinary local user; deleting it ends them."""
|
||||
portal_reply = Mock(
|
||||
status_code=200,
|
||||
json=Mock(
|
||||
return_value={"user_id": "portal-user-9", "email": "remote@example.com"}
|
||||
),
|
||||
)
|
||||
with patch("app.api.routes.cloud.httpx.post", return_value=portal_reply) as post:
|
||||
added = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/users",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": "ABCD-EFGH"},
|
||||
)
|
||||
assert added.status_code == 200, added.text
|
||||
assert post.call_args.kwargs["headers"]["Authorization"].startswith("Bearer ")
|
||||
body = added.json()
|
||||
assert body["email"] == "remote@example.com"
|
||||
assert body["portal_sub"] == "portal-user-9"
|
||||
# Never a superuser: a remote user must not be able to admit anyone else.
|
||||
assert body["is_superuser"] is False
|
||||
|
||||
theirs = portal_token(portal_key, subject="portal-user-9")
|
||||
resolved = user_from_token(db, theirs)
|
||||
assert resolved is not None and resolved.email == "remote@example.com"
|
||||
|
||||
with patch("app.api.routes.cloud.httpx.post", return_value=portal_reply):
|
||||
again = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/users",
|
||||
headers=superuser_token_headers,
|
||||
json={"code": "ABCD-EFGH"},
|
||||
)
|
||||
assert again.status_code == 409
|
||||
|
||||
with patch(
|
||||
"app.api.routes.cloud.httpx.delete", return_value=Mock(status_code=200)
|
||||
) as delete:
|
||||
removed = client.delete(
|
||||
f"{settings.API_V1_STR}/users/{body['id']}", headers=superuser_token_headers
|
||||
)
|
||||
assert removed.status_code == 200, removed.text
|
||||
assert delete.call_args.args[0].endswith("/installation-members/portal-user-9")
|
||||
db.expire_all()
|
||||
assert user_from_token(db, theirs) is None
|
||||
|
||||
|
||||
def test_adding_a_remote_user_needs_a_superuser(
|
||||
client: TestClient,
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
normal_user_token_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Widening who can reach this installation stays a superuser's decision."""
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/users",
|
||||
headers=normal_user_token_headers,
|
||||
json={"code": "ABCD-EFGH"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_enrolling_against_a_portal_without_an_owner_is_refused(
|
||||
client: TestClient,
|
||||
superuser_token_headers: dict[str, str],
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> None:
|
||||
"""A portal too old to name the owner would leave nobody mapped here."""
|
||||
original = settings.CLOUD_CONFIG_FILE
|
||||
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("old-portal") / "cloud.json"
|
||||
reply = Mock(
|
||||
status_code=200,
|
||||
json=Mock(
|
||||
return_value={
|
||||
"installation_id": str(uuid.uuid4()),
|
||||
"installation_token": "t",
|
||||
"ws_url": f"{ISSUER}/api/v1/tunnel/attach",
|
||||
"issuer": ISSUER,
|
||||
"jwks": {"keys": []},
|
||||
}
|
||||
),
|
||||
)
|
||||
try:
|
||||
with patch("app.api.routes.cloud.httpx.AsyncClient") as client_cls:
|
||||
client_cls.return_value.__aenter__.return_value.post = AsyncMock(
|
||||
return_value=reply
|
||||
)
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/enroll",
|
||||
headers=superuser_token_headers,
|
||||
json={"portal_url": ISSUER, "claim_code": "ABCD-EFGH"},
|
||||
)
|
||||
assert response.status_code == 502
|
||||
# Nothing was written: an enrolment nobody can act as is not one to keep.
|
||||
assert not settings.CLOUD_CONFIG_FILE.exists()
|
||||
finally:
|
||||
settings.CLOUD_CONFIG_FILE = original
|
||||
|
||||
Reference in New Issue
Block a user