diff --git a/ROADMAP.md b/ROADMAP.md index 7b6e55b..dcb0536 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 year-long token must never sit in. What is waiting on a code is named 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 diff --git a/backend/app/alembic/versions/c4e8b2170f93_add_portal_sub_to_user.py b/backend/app/alembic/versions/c4e8b2170f93_add_portal_sub_to_user.py new file mode 100644 index 0000000..e618bbd --- /dev/null +++ b/backend/app/alembic/versions/c4e8b2170f93_add_portal_sub_to_user.py @@ -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') diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 5596dcf..a8cc262 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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, diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py index 983e410..f537855 100644 --- a/backend/app/api/routes/cloud.py +++ b/backend/app/api/routes/cloud.py @@ -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 ) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index de826f4..82385f8 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -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") diff --git a/backend/app/cloud/config.py b/backend/app/cloud/config.py index 73d179e..540837b 100644 --- a/backend/app/cloud/config.py +++ b/backend/app/cloud/config.py @@ -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} diff --git a/backend/app/models.py b/backend/app/models.py index 3adb9f6..6bedd8a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 72237f1..748fb1c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_cloud.py b/backend/tests/test_cloud.py index c2c8b2a..c23573e 100644 --- a/backend/tests/test_cloud.py +++ b/backend/tests/test_cloud.py @@ -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 diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index ba22498..f5e687b 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -2214,6 +2214,20 @@ export const RegisteredClientsSchema = { title: 'RegisteredClients' } 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 = { properties: { events: { @@ -2799,6 +2813,17 @@ export const UserPublicSchema = { format: 'uuid', title: 'Id' }, + portal_sub: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Portal Sub' + }, created_at: { anyOf: [ { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index aa4c463..53d828f 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; 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 { /** @@ -128,8 +128,10 @@ export class CloudService { * Enroll * 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. * @param data The data for the request. * @param data.requestBody * @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 { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/cloud/users', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + /** * Disconnect * Sever the connection from this side. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index d71d107..fc45a51 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -801,6 +801,10 @@ export type RegisteredClients = { count: number; }; +export type RemoteUserBody = { + code: string; +}; + /** * Which events go to which channels. */ @@ -964,6 +968,7 @@ export type UserPublic = { is_superuser?: boolean; full_name?: (string | null); id: string; + portal_sub?: (string | null); created_at?: (string | null); }; @@ -1092,6 +1097,12 @@ export type CloudEnrollData = { export type CloudEnrollResponse = (Message); +export type CloudAddRemoteUserData = { + requestBody: RemoteUserBody; +}; + +export type CloudAddRemoteUserResponse = (UserPublic); + export type CloudDisconnectResponse = (Message); export type DashboardsReadDashboardsResponse = (DashboardsPublic); diff --git a/frontend/src/components/Admin/columns.tsx b/frontend/src/components/Admin/columns.tsx index 8b0fa13..e2d8cd9 100644 --- a/frontend/src/components/Admin/columns.tsx +++ b/frontend/src/components/Admin/columns.tsx @@ -35,7 +35,16 @@ export const columns: ColumnDef[] = [ accessorKey: "email", header: "Email", cell: ({ row }) => ( - {row.original.email} +
+ {row.original.email} + {/* Signs in through the portal, never here — worth saying, because + deleting this row is what ends their remote access. */} + {row.original.portal_sub && ( + + Portal + + )} +
), }, { diff --git a/frontend/src/components/UserSettings/RemoteAccess.tsx b/frontend/src/components/UserSettings/RemoteAccess.tsx index c3ac58e..f767775 100644 --- a/frontend/src/components/UserSettings/RemoteAccess.tsx +++ b/frontend/src/components/UserSettings/RemoteAccess.tsx @@ -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 - * account that enrolled, and this screen says which one. Everything here is - * optional — an installation nobody enrolls never contacts anything. + * Deliberately blunt about what it grants: the account that enrolled is what + * the portal owner's sessions act as, and this screen says which one. Anyone + * 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() { const queryClient = useQueryClient() const { showErrorToast, showSuccessToast } = useCustomToast() const [portalUrl, setPortalUrl] = useState(DEFAULT_PORTAL) const [code, setCode] = useState("") + const [joinCode, setJoinCode] = useState("") const [confirmDisconnect, setConfirmDisconnect] = useState(false) const { data: status } = useQuery({ @@ -67,6 +71,18 @@ export function RemoteAccess() { 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({ mutationFn: () => CloudService.disconnect(), onSuccess: () => { @@ -90,39 +106,80 @@ export function RemoteAccess() { {status.enrolled ? ( -
-
- - {status.connected - ? "Connected" - : status.last_error - ? `Reconnecting — ${status.last_error}` - : "Reconnecting…"} - - {status.portal_url ?? "—"} - - {status.portal_account ?? "—"} - - Anyone signed in to the portal for this installation gets this - account's rights here. - - - - - {status.installation_id ?? "—"} - - -
-
- + <> +
+
+ + {status.connected + ? "Connected" + : status.last_error + ? `Reconnecting — ${status.last_error}` + : "Reconnecting…"} + + {status.portal_url ?? "—"} + + {status.portal_account ?? "—"} + + Portal sessions of this account get this account's rights + here. Anyone else gets in only once added below, as their own + user. + + + + + {status.installation_id ?? "—"} + + +
+
+ +
-
+ +
+
+

Remote users

+

+ 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. +

+
+
+ + + setJoinCode(event.target.value.toUpperCase()) + } + /> +

+ They get a code at fluksio.com → Installations → Join an + installation. Added users appear under Admin → Users; deleting + them there ends their access. +

+
+
+ +
+
+ ) : (