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

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:
2026-08-21 11:34:01 +02:00
co-authored by Claude Opus 5
parent 4e253f41ae
commit 81dad0a449
14 changed files with 553 additions and 64 deletions
+16
View File
@@ -324,6 +324,22 @@ Shares components with the admin view. See `docs/architecture/structure.canvas`
credential is traded for the hub's cookie rather than carried in the URL a credential is traded for the hub's cookie rather than carried in the URL a
year-long token must never sit in. What is waiting on a code is named year-long token must never sit in. What is waiting on a code is named
before anyone approves it before anyone approves it
- [x] Remote users: a portal account other than the installation's owner reaches
it, as a local user of its own. The mirror of the claim code — the person
wanting in mints a code on their portal account, and a superuser here
redeems it, so admission is a local decision made by someone who already
had the right to grant remote access at all. The installation redeems it
holding its tunnel credential rather than a portal session, which is what
stops an admitted user from admitting anyone else; the account they get is
never a superuser, so the same is true from this side. `user.portal_sub`
is where a portal identity meets a local account, set for the enrolling
superuser at enrolment and for each admitted user after. A proxy token now
names a person rather than resolving to whoever enrolled, and an unmapped
identity resolves to no user — so deleting the local row is the whole of
the revocation, immediate even against a credential already in flight and
even when the portal cannot be reached to be told. The hub's `_authorize`
grew one member lookup beside the owner comparison, which is the only
thing that changed about the tunnel
## Phase 5 — Website and docs ## Phase 5 — Website and docs
@@ -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
View File
@@ -6,7 +6,7 @@ from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError from jwt.exceptions import InvalidTokenError
from pydantic import ValidationError from pydantic import ValidationError
from sqlmodel import Session from sqlmodel import Session, select
from app.cloud import config as cloud_config from app.cloud import config as cloud_config
from app.core import security 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 The last branch is the seam a hosted deployment widens: a portal this
installation was enrolled with signs tokens with a key pinned at installation was enrolled with signs tokens with a key pinned at
enrolment, and they resolve to the local account that performed it. With enrolment, and they name the portal account holding them, which resolves
no enrolment the branch raises immediately, so an offline installation to whichever local account was mapped to it — the superuser who enrolled,
pays nothing for the possibility. A screen that paired through that portal 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 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 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. 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 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( def user_from_token(
session: Session, token: str, request: Request | None = None session: Session, token: str, request: Request | None = None
) -> User | None: ) -> User | None:
@@ -156,7 +174,7 @@ def user_from_token(
token_data = TokenPayload(**decode_token(token, request)) token_data = TokenPayload(**decode_token(token, request))
except (InvalidTokenError, ValidationError): except (InvalidTokenError, ValidationError):
return None return None
user = session.get(User, token_data.sub) user = _user_for(session, token_data)
if user is None or not user.is_active: if user is None or not user.is_active:
return None return None
return user return user
@@ -178,7 +196,7 @@ def get_current_user(request: Request, session: SessionDep, token: TokenDep) ->
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", 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: if user is None or not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
+150 -5
View File
@@ -3,22 +3,36 @@
Entirely optional, and superuser-only to change: enrolling grants a remote 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 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. 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 from __future__ import annotations
import asyncio import asyncio
import logging import logging
import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field 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.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__) logger = logging.getLogger(__name__)
@@ -61,12 +75,14 @@ def read_status(request: Request) -> dict[str, Any]:
response_model=Message, response_model=Message,
) )
async def enroll( async def enroll(
request: Request, current_user: CurrentUser, body: EnrollBody request: Request, session: SessionDep, current_user: CurrentUser, body: EnrollBody
) -> Message: ) -> Message:
"""Redeem a claim code and start dialling the portal. """Redeem a claim code and start dialling the portal.
The account performing this is recorded as the one every portal session The account performing this is mapped to the portal account that owns the
will act as. There is no way to widen that later from the portal side. 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(): if cloud_config.exists():
raise HTTPException( raise HTTPException(
@@ -96,6 +112,16 @@ async def enroll(
) )
data = response.json() 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( config = cloud_config.CloudConfig(
portal_url=base, portal_url=base,
ws_url=data["ws_url"], ws_url=data["ws_url"],
@@ -110,10 +136,129 @@ async def enroll(
portal_account=current_user.email, portal_account=current_user.email,
) )
cloud_config.save(config) 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) _start_connector(request.app)
return Message(message="Connected to the portal") 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( @router.delete(
"", dependencies=[Depends(get_current_active_superuser)], response_model=Message "", dependencies=[Depends(get_current_active_superuser)], response_model=Message
) )
+9
View File
@@ -10,6 +10,7 @@ from app.api.deps import (
SessionDep, SessionDep,
get_current_active_superuser, get_current_active_superuser,
) )
from app.api.routes.cloud import forget_remote_user
from app.core.config import settings from app.core.config import settings
from app.core.security import get_password_hash, verify_password from app.core.security import get_password_hash, verify_password
from app.models import ( from app.models import (
@@ -136,8 +137,11 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
raise HTTPException( raise HTTPException(
status_code=403, detail="Super users are not allowed to delete themselves" status_code=403, detail="Super users are not allowed to delete themselves"
) )
portal_sub = current_user.portal_sub
session.delete(current_user) session.delete(current_user)
session.commit() session.commit()
if portal_sub:
forget_remote_user(portal_sub)
return Message(message="User deleted successfully") return Message(message="User deleted successfully")
@@ -223,6 +227,11 @@ def delete_user(
raise HTTPException( raise HTTPException(
status_code=403, detail="Super users are not allowed to delete themselves" status_code=403, detail="Super users are not allowed to delete themselves"
) )
portal_sub = user.portal_sub
session.delete(user) session.delete(user)
session.commit() 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") return Message(message="User deleted successfully")
+14 -6
View File
@@ -46,9 +46,11 @@ class CloudConfig:
token: str token: str
issuer: str issuer: str
jwks: dict[str, Any] jwks: dict[str, Any]
#: The local account every portal session acts as. Recorded at enrolment #: The local account this installation acts as on its own behalf: what the
#: from whoever performed it, so remote access can never exceed the rights #: health summary is collected as, and what a screen paired through the
#: of the person who granted it. #: 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 local_user_id: str
enrolled_at: str enrolled_at: str
portal_account: str | None = None 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} return {"sub": config.local_user_id, "panel": panel}
if scope != "proxy": if scope != "proxy":
raise InvalidTokenError("not a proxy token") raise InvalidTokenError("not a proxy token")
# Every portal session acts as the enrolling local user. Who they are on # A portal session names the person holding it, and that name is the whole
# the portal is kept for the audit trail, not for authorization. # of their identity here: the caller resolves it to the local account it
return {"sub": config.local_user_id, "portal_sub": claims.get("sub")} # 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}
+11
View File
@@ -50,6 +50,11 @@ class UpdatePassword(SQLModel):
class User(UserBase, table=True): class User(UserBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
hashed_password: str 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( created_at: datetime | None = Field(
default_factory=get_datetime_utc, default_factory=get_datetime_utc,
sa_type=DateTime(timezone=True), # type: ignore 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 # Properties to return via API, id is always required
class UserPublic(UserBase): class UserPublic(UserBase):
id: uuid.UUID id: uuid.UUID
portal_sub: str | None = None
created_at: datetime | None = None created_at: datetime | None = None
@@ -81,6 +87,11 @@ class Token(SQLModel):
# Contents of JWT token # Contents of JWT token
class TokenPayload(SQLModel): class TokenPayload(SQLModel):
sub: str | None = None 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): class NewPassword(SQLModel):
+8
View File
@@ -107,6 +107,14 @@ def enrolled(
portal_account=settings.FIRST_SUPERUSER, 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 yield local_user
local_user.portal_sub = None
db.add(local_user)
db.commit()
cloud_config.delete() cloud_config.delete()
settings.CLOUD_CONFIG_FILE = original settings.CLOUD_CONFIG_FILE = original
+116 -7
View File
@@ -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 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 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 enrolled it, and even then it grants exactly the rights of the local account
did. 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 from __future__ import annotations
import uuid import uuid
from unittest.mock import AsyncMock, Mock, patch
import jwt import jwt
import pytest import pytest
@@ -37,14 +39,18 @@ def test_portal_token_is_refused_when_not_enrolled(
settings.CLOUD_CONFIG_FILE = original 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 enrolled: User, portal_key: rsa.RSAPrivateKey, db: Session
) -> None: ) -> None:
"""The token names a person on the portal; the mapping names them here."""
claims = decode_token(portal_token(portal_key)) claims = decode_token(portal_token(portal_key))
assert claims["sub"] == str(enrolled.id) # No local account of its own: the payload carries who they are on the
# Who they are on the portal is carried for the record, not for rights. # 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 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)) == enrolled
assert user_from_token(db, portal_token(portal_key, subject="nobody")) is None
def test_token_for_another_installation_is_refused( def test_token_for_another_installation_is_refused(
@@ -77,7 +83,7 @@ def test_disconnecting_ends_remote_access(
enrolled: User, portal_key: rsa.RSAPrivateKey enrolled: User, portal_key: rsa.RSAPrivateKey
) -> None: ) -> None:
"""Deleting the config is the whole of the local revocation.""" """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() cloud_config.delete()
with pytest.raises(jwt.exceptions.InvalidTokenError): with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key)) 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.""" """It would otherwise fall through to the account it borrows."""
with pytest.raises(jwt.exceptions.InvalidTokenError): with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key, subject="", scope="panel")) 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
+25
View File
@@ -2214,6 +2214,20 @@ export const RegisteredClientsSchema = {
title: 'RegisteredClients' title: 'RegisteredClients'
} as const; } as const;
export const RemoteUserBodySchema = {
properties: {
code: {
type: 'string',
maxLength: 32,
minLength: 1,
title: 'Code'
}
},
type: 'object',
required: ['code'],
title: 'RemoteUserBody'
} as const;
export const RuleSchema = { export const RuleSchema = {
properties: { properties: {
events: { events: {
@@ -2799,6 +2813,17 @@ export const UserPublicSchema = {
format: 'uuid', format: 'uuid',
title: 'Id' title: 'Id'
}, },
portal_sub: {
anyOf: [
{
type: 'string'
},
{
type: 'null'
}
],
title: 'Portal Sub'
},
created_at: { created_at: {
anyOf: [ anyOf: [
{ {
+31 -3
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise'; import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
export class AlertsService { export class AlertsService {
/** /**
@@ -128,8 +128,10 @@ export class CloudService {
* Enroll * Enroll
* Redeem a claim code and start dialling the portal. * Redeem a claim code and start dialling the portal.
* *
* The account performing this is recorded as the one every portal session * The account performing this is mapped to the portal account that owns the
* will act as. There is no way to widen that later from the portal side. * 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.
* @param data The data for the request. * @param data The data for the request.
* @param data.requestBody * @param data.requestBody
* @returns Message Successful Response * @returns Message Successful Response
@@ -147,6 +149,32 @@ export class CloudService {
}); });
} }
/**
* Add Remote User
* 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.
* @param data The data for the request.
* @param data.requestBody
* @returns UserPublic Successful Response
* @throws ApiError
*/
public static addRemoteUser(data: CloudAddRemoteUserData): CancelablePromise<CloudAddRemoteUserResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/cloud/users',
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/** /**
* Disconnect * Disconnect
* Sever the connection from this side. * Sever the connection from this side.
+11
View File
@@ -801,6 +801,10 @@ export type RegisteredClients = {
count: number; count: number;
}; };
export type RemoteUserBody = {
code: string;
};
/** /**
* Which events go to which channels. * Which events go to which channels.
*/ */
@@ -964,6 +968,7 @@ export type UserPublic = {
is_superuser?: boolean; is_superuser?: boolean;
full_name?: (string | null); full_name?: (string | null);
id: string; id: string;
portal_sub?: (string | null);
created_at?: (string | null); created_at?: (string | null);
}; };
@@ -1092,6 +1097,12 @@ export type CloudEnrollData = {
export type CloudEnrollResponse = (Message); export type CloudEnrollResponse = (Message);
export type CloudAddRemoteUserData = {
requestBody: RemoteUserBody;
};
export type CloudAddRemoteUserResponse = (UserPublic);
export type CloudDisconnectResponse = (Message); export type CloudDisconnectResponse = (Message);
export type DashboardsReadDashboardsResponse = (DashboardsPublic); export type DashboardsReadDashboardsResponse = (DashboardsPublic);
+10 -1
View File
@@ -35,7 +35,16 @@ export const columns: ColumnDef<UserTableData>[] = [
accessorKey: "email", accessorKey: "email",
header: "Email", header: "Email",
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.email}</span> <div className="flex items-center gap-2">
<span className="text-muted-foreground">{row.original.email}</span>
{/* Signs in through the portal, never here worth saying, because
deleting this row is what ends their remote access. */}
{row.original.portal_sub && (
<Badge variant="outline" className="text-xs">
Portal
</Badge>
)}
</div>
), ),
}, },
{ {
@@ -29,17 +29,21 @@ type CloudStatus = {
} }
/** /**
* Connecting this installation to a Fluksio portal, or cutting it loose. * Connecting this installation to a Fluksio portal, or cutting it loose, and
* admitting other portal accounts to it.
* *
* Deliberately blunt about what it grants: a remote session acts as the * Deliberately blunt about what it grants: the account that enrolled is what
* account that enrolled, and this screen says which one. Everything here is * the portal owner's sessions act as, and this screen says which one. Anyone
* optional an installation nobody enrolls never contacts anything. * else gets in only by being added here, as a local user of their own.
* Everything here is optional an installation nobody enrolls never contacts
* anything.
*/ */
export function RemoteAccess() { export function RemoteAccess() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { showErrorToast, showSuccessToast } = useCustomToast() const { showErrorToast, showSuccessToast } = useCustomToast()
const [portalUrl, setPortalUrl] = useState(DEFAULT_PORTAL) const [portalUrl, setPortalUrl] = useState(DEFAULT_PORTAL)
const [code, setCode] = useState("") const [code, setCode] = useState("")
const [joinCode, setJoinCode] = useState("")
const [confirmDisconnect, setConfirmDisconnect] = useState(false) const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const { data: status } = useQuery<CloudStatus>({ const { data: status } = useQuery<CloudStatus>({
@@ -67,6 +71,18 @@ export function RemoteAccess() {
onError: handleError.bind(showErrorToast), onError: handleError.bind(showErrorToast),
}) })
const addRemoteUser = useMutation({
mutationFn: () =>
CloudService.addRemoteUser({ requestBody: { code: joinCode.trim() } }),
onSuccess: (user) => {
setJoinCode("")
showSuccessToast(`Added ${user.email}`)
// They are an ordinary user from here on, and the Admin page lists them.
queryClient.invalidateQueries({ queryKey: ["users"] })
},
onError: handleError.bind(showErrorToast),
})
const disconnect = useMutation({ const disconnect = useMutation({
mutationFn: () => CloudService.disconnect(), mutationFn: () => CloudService.disconnect(),
onSuccess: () => { onSuccess: () => {
@@ -90,39 +106,80 @@ export function RemoteAccess() {
</div> </div>
{status.enrolled ? ( {status.enrolled ? (
<div className="flex flex-col gap-4 rounded-lg border border-border p-4"> <>
<dl className="grid gap-3 sm:grid-cols-2"> <div className="flex flex-col gap-4 rounded-lg border border-border p-4">
<Field label="Status"> <dl className="grid gap-3 sm:grid-cols-2">
{status.connected <Field label="Status">
? "Connected" {status.connected
: status.last_error ? "Connected"
? `Reconnecting — ${status.last_error}` : status.last_error
: "Reconnecting…"} ? `Reconnecting${status.last_error}`
</Field> : "Reconnecting…"}
<Field label="Portal">{status.portal_url ?? "—"}</Field> </Field>
<Field label="Acting as"> <Field label="Portal">{status.portal_url ?? "—"}</Field>
{status.portal_account ?? "—"} <Field label="Acting as">
<span className="mt-1 block text-xs text-muted-foreground"> {status.portal_account ?? "—"}
Anyone signed in to the portal for this installation gets this <span className="mt-1 block text-xs text-muted-foreground">
account's rights here. Portal sessions of this account get this account's rights
</span> here. Anyone else gets in only once added below, as their own
</Field> user.
<Field label="Installation"> </span>
<span className="font-mono text-xs"> </Field>
{status.installation_id ?? "—"} <Field label="Installation">
</span> <span className="font-mono text-xs">
</Field> {status.installation_id ?? "—"}
</dl> </span>
<div> </Field>
<Button </dl>
variant="destructive" <div>
className="rounded-full" <Button
onClick={() => setConfirmDisconnect(true)} variant="destructive"
> className="rounded-full"
Disconnect onClick={() => setConfirmDisconnect(true)}
</Button> >
Disconnect
</Button>
</div>
</div> </div>
</div>
<div className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div>
<h3 className="font-medium">Remote users</h3>
<p className="text-sm text-muted-foreground">
Let someone else reach this installation through the portal.
They get a user of their own here not yours, and never a
superuser, so they cannot pass access on.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="join-code">Code</Label>
<Input
id="join-code"
value={joinCode}
placeholder="XXXX-XXXX"
className="font-mono tracking-widest"
onChange={(event) =>
setJoinCode(event.target.value.toUpperCase())
}
/>
<p className="text-xs text-muted-foreground">
They get a code at fluksio.com Installations Join an
installation. Added users appear under Admin Users; deleting
them there ends their access.
</p>
</div>
<div>
<Button
className="rounded-full"
variant="outline"
disabled={!joinCode.trim() || addRemoteUser.isPending}
onClick={() => addRemoteUser.mutate()}
>
{addRemoteUser.isPending ? "Adding…" : "Add remote user"}
</Button>
</div>
</div>
</>
) : ( ) : (
<div className="flex flex-col gap-4 rounded-lg border border-border p-4"> <div className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div className="grid gap-2"> <div className="grid gap-2">