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