diff --git a/estela-api/api/permissions.py b/estela-api/api/permissions.py index 31ebbf57..9eb6142d 100644 --- a/estela-api/api/permissions.py +++ b/estela-api/api/permissions.py @@ -80,6 +80,10 @@ class IsAdminOrReadOnly(BasePermission): Custom permission to only allow admins or developers of an object to edit it. """ + # Named so it cannot be mistaken for the API key's own refusal: a key with the + # right scope still gets nowhere if its owner is a viewer on the project. + message = "Your role on this project does not allow this action." + def has_permission(self, request, view): pid = view.kwargs.get("pid") # Read permissions are allowed to any request, diff --git a/estela-api/api/serializers/auth.py b/estela-api/api/serializers/auth.py index 09939669..fca95a78 100644 --- a/estela-api/api/serializers/auth.py +++ b/estela-api/api/serializers/auth.py @@ -162,3 +162,19 @@ def validate(serlf, attrs): {"new_password": "New passwords do not match."} ) return attrs + + +class WhoAmISerializer(serializers.Serializer): + """Who the caller is, and what the credential in hand may do. + + A key carries no username and no visible permissions, so a program holding + one cannot tell whether it is about to be refused until it tries. + """ + + username = serializers.CharField(read_only=True) + email = serializers.CharField(read_only=True) + scopes = serializers.ListField( + child=serializers.CharField(), + read_only=True, + help_text="Extra permissions of the API key used. Absent for a session.", + ) diff --git a/estela-api/api/views/auth.py b/estela-api/api/views/auth.py index f67708e4..b7cf1d43 100644 --- a/estela-api/api/views/auth.py +++ b/estela-api/api/views/auth.py @@ -22,6 +22,8 @@ from rest_framework.response import Response from api import errors +from api.authentication import ApiKeyAuthentication +from core.models import ApiKey from api.captcha import EXPIRED_TOKEN, get_client_ip, verify_captcha from api.exceptions import EmailServiceError, UserNotFoundError from api.permissions import IsProfileUser @@ -33,6 +35,7 @@ TokenSerializer, UserProfileSerializer, UserSerializer, + WhoAmISerializer, ) from api.tokens import account_reset_token from core.views import ( @@ -97,6 +100,24 @@ def login(self, request, *args, **kwargs): token, _ = Token.objects.get_or_create(user=user) return Response(TokenSerializer(token).data) + @swagger_auto_schema( + methods=["GET"], responses={status.HTTP_200_OK: WhoAmISerializer()} + ) + @action( + methods=["GET"], + detail=False, + permission_classes=[permissions.IsAuthenticated], + authentication_classes=[ApiKeyAuthentication, TokenAuthentication], + serializer_class=WhoAmISerializer, + ) + def whoami(self, request, *args, **kwargs): + """Who the caller is. An API key carries no username, so this is how a + program finds out which account it is acting as.""" + data = {"username": request.user.username, "email": request.user.email} + if isinstance(request.auth, ApiKey): + data["scopes"] = request.auth.scopes + return Response(data) + @swagger_auto_schema( methods=["POST"], responses={status.HTTP_200_OK: TokenSerializer()} ) diff --git a/estela-api/docs/api.yaml b/estela-api/docs/api.yaml index 5c409f6c..e24c565b 100644 --- a/estela-api/docs/api.yaml +++ b/estela-api/docs/api.yaml @@ -322,6 +322,21 @@ paths: tags: - api parameters: [] + /api/auth/whoami: + get: + operationId: api_auth_whoami + description: |- + Who the caller is. An API key carries no username, so this is how a + program finds out which account it is acting as. + parameters: [] + responses: + '200': + description: '' + schema: + $ref: '#/definitions/WhoAmI' + tags: + - api + parameters: [] /api/notifications: get: operationId: api_notifications_list @@ -2076,6 +2091,26 @@ definitions: recaptcha_token: title: Recaptcha token type: string + WhoAmI: + type: object + properties: + username: + title: Username + type: string + readOnly: true + minLength: 1 + email: + title: Email + type: string + readOnly: true + minLength: 1 + scopes: + description: Extra permissions of the API key used. Absent for a session. + type: array + items: + type: string + minLength: 1 + readOnly: true ProjectDetail: description: Project where the activity was performed. type: object diff --git a/estela-web/src/pages/DeployListPage/index.tsx b/estela-web/src/pages/DeployListPage/index.tsx index 65d66d98..09a6f75f 100644 --- a/estela-web/src/pages/DeployListPage/index.tsx +++ b/estela-web/src/pages/DeployListPage/index.tsx @@ -386,10 +386,20 @@ export class DeployListPage extends Component, $ git clone https://github.com/bitmakerla/scraping-demo-project

$ cd scraping-demo-project

-

$ estela login

-

Host [http://localhost]: {API_BASE_URL}

-

Username: {AuthService.getUserUsername()}

-

Password:

+

$ estela set-host {API_BASE_URL}

+

 

+

+ # optional — skip if you already have a key +

+

$ estela create-api-token

+

Opening your browser…

+

 

+

$ estela set-token

+

+ Paste your API key: •••••••• +

+

Logged in as {AuthService.getUserUsername()}

+

 

$ estela init {this.projectId}

$ estela deploy

@@ -403,10 +413,20 @@ export class DeployListPage extends Component,

$ cd <project_name>

-

$ estela login

-

Host [http://localhost]: {API_BASE_URL}

-

Username: {AuthService.getUserUsername()}

-

Password:

+

$ estela set-host {API_BASE_URL}

+

 

+

+ # optional — skip if you already have a key +

+

$ estela create-api-token

+

Opening your browser…

+

 

+

$ estela set-token

+

+ Paste your API key: •••••••• +

+

Logged in as {AuthService.getUserUsername()}

+

 

$ estela init {this.projectId}

$ estela deploy

diff --git a/estela-web/src/pages/LoginPage/index.tsx b/estela-web/src/pages/LoginPage/index.tsx index 00dd9e88..ca2ee462 100644 --- a/estela-web/src/pages/LoginPage/index.tsx +++ b/estela-web/src/pages/LoginPage/index.tsx @@ -7,7 +7,7 @@ import "./styles.scss"; import history from "../../history"; import { ApiService, AuthService } from "../../services"; import { ApiAuthLoginRequest, Token } from "../../services/api"; -import { handleInvalidDataError } from "../../utils"; +import { handleInvalidDataError, safeNextPath } from "../../utils"; import { UserContext, UserContextProps } from "../../context"; import { EstelaBanner } from "../../components"; import { RECAPTCHA_ENABLED, RECAPTCHA_SITE_KEY, REGISTER_PAGE_ENABLED } from "../../constants"; @@ -38,7 +38,7 @@ export class LoginPage extends Component { if (AuthService.getUserRole() && updateRole) { updateRole(AuthService.getUserRole() ?? ""); } - history.push("/projects"); + history.push(safeNextPath(window.location.search)); } } @@ -76,7 +76,7 @@ export class LoginPage extends Component { updateEmail(response.user.email ?? ""); } this.setState({ loading: false }); - history.push("/projects"); + history.push(safeNextPath(window.location.search)); }, (error: unknown) => { handleInvalidDataError(error); diff --git a/estela-web/src/pages/SettingsApiKeysPage/index.tsx b/estela-web/src/pages/SettingsApiKeysPage/index.tsx index bae0c8c6..28c142ef 100644 --- a/estela-web/src/pages/SettingsApiKeysPage/index.tsx +++ b/estela-web/src/pages/SettingsApiKeysPage/index.tsx @@ -17,7 +17,7 @@ import { import { CopyOutlined, QuestionCircleOutlined, WarningOutlined } from "@ant-design/icons"; import "./styles.scss"; -import { ApiService } from "../../services"; +import { ApiService, AuthService } from "../../services"; import { ApiKey, ApiKeyCreateExpiresInDaysEnum, ApiKeyCreateScopesEnum } from "../../services/api"; import { Spin } from "../../shared"; @@ -37,6 +37,10 @@ const DURATIONS = [ { value: ApiKeyCreateExpiresInDaysEnum._365, label: "1 year" }, ]; +// estela-cli sends people here with ?cli=1. It needs everything except reading +// job data is optional — it deploys, runs jobs and manages the project. +const CLI_SCOPES = ["data", "run", "manage"]; + const FIELD_HELP = { name: "Where this key will be used, so you can recognise it later. For example, the DAG or the machine.", permissions: "Every key can list your projects, spiders and jobs. Add only what this one needs.", @@ -62,6 +66,7 @@ interface ApiKeysPageState { newName: string; newScopes: string[]; newDuration: ApiKeyCreateExpiresInDaysEnum; + forCli: boolean; createdKey: string | null; revoking: number | null; } @@ -75,6 +80,7 @@ export class SettingsApiKeysPage extends Component { newName: "", newScopes: [], newDuration: ApiKeyCreateExpiresInDaysEnum._90, + forCli: false, createdKey: null, revoking: null, }; @@ -83,6 +89,17 @@ export class SettingsApiKeysPage extends Component { async componentDidMount(): Promise { await this.loadKeys(); + if (new URLSearchParams(window.location.search).get("cli") === "1") { + // Prefilled, never created on arrival: a link someone sends you must not + // mint a key by itself. The duration is left out so the key inherits + // whatever default this deployment sets. + this.setState({ + createModal: true, + forCli: true, + newName: `estela-cli@${AuthService.getUserUsername() ?? ""}`, + newScopes: CLI_SCOPES, + }); + } } loadKeys = async (): Promise => { @@ -157,6 +174,7 @@ export class SettingsApiKeysPage extends Component { openCreateModal = (): void => { this.setState({ createModal: true, + forCli: false, newName: "", newScopes: [], newDuration: ApiKeyCreateExpiresInDaysEnum._90, @@ -245,7 +263,7 @@ export class SettingsApiKeysPage extends Component { ]; render(): JSX.Element { - const { keys, loaded, createModal, creating, newName, newScopes, newDuration, createdKey } = this.state; + const { keys, loaded, createModal, creating, newName, newScopes, newDuration, forCli, createdKey } = this.state; if (!loaded) return ; @@ -284,7 +302,11 @@ export class SettingsApiKeysPage extends Component { style={{ overflow: "hidden", padding: 0 }} open={createModal} width={700} - title={

NEW API KEY

} + title={ +

+ {forCli ? "NEW KEY FOR ESTELA-CLI" : "NEW API KEY"} +

+ } footer={null} onCancel={() => this.setState({ createModal: false })} > @@ -386,6 +408,15 @@ export class SettingsApiKeysPage extends Component { Copy + {forCli && ( + + + Copy the key above, then run{" "} + estela set-token and paste + it when asked. + + + )}