Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions estela-api/api/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions estela-api/api/serializers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
21 changes: 21 additions & 0 deletions estela-api/api/views/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +35,7 @@
TokenSerializer,
UserProfileSerializer,
UserSerializer,
WhoAmISerializer,
)
from api.tokens import account_reset_token
from core.views import (
Expand Down Expand Up @@ -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()}
)
Expand Down
35 changes: 35 additions & 0 deletions estela-api/docs/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
36 changes: 28 additions & 8 deletions estela-web/src/pages/DeployListPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,20 @@ export class DeployListPage extends Component<RouteComponentProps<RouteParams>,
$ git clone https://github.com/bitmakerla/scraping-demo-project
</p>
<p className="text-white">$ cd scraping-demo-project</p>
<p className="text-white">$ estela login</p>
<p className="text-white">Host [http://localhost]: {API_BASE_URL}</p>
<p className="text-white">Username: {AuthService.getUserUsername()}</p>
<p className="text-white">Password:</p>
<p className="break-words text-white">$ estela set-host {API_BASE_URL}</p>
<p className="text-estela-black-low">&nbsp;</p>
<p className="text-estela-black-low">
# optional &mdash; skip if you already have a key
</p>
<p className="text-white">$ estela create-api-token</p>
<p className="text-estela-black-low">Opening your browser&hellip;</p>
<p className="text-estela-black-low">&nbsp;</p>
<p className="text-white">$ estela set-token</p>
<p className="text-estela-black-low">
Paste your API key: &bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;
</p>
<p className="text-white">Logged in as {AuthService.getUserUsername()}</p>
<p className="text-estela-black-low">&nbsp;</p>
<p className="break-words text-white">$ estela init {this.projectId}</p>
<p className="text-white">$ estela deploy</p>
</div>
Expand All @@ -403,10 +413,20 @@ export class DeployListPage extends Component<RouteComponentProps<RouteParams>,
</p>
<div className="mt-4 rounded-md p-6 bg-back-code font-courier text-sm">
<p className="text-white">$ cd &lt;project_name&gt;</p>
<p className="text-white">$ estela login</p>
<p className="text-white">Host [http://localhost]: {API_BASE_URL}</p>
<p className="text-white">Username: {AuthService.getUserUsername()}</p>
<p className="text-white">Password:</p>
<p className="break-words text-white">$ estela set-host {API_BASE_URL}</p>
<p className="text-estela-black-low">&nbsp;</p>
<p className="text-estela-black-low">
# optional &mdash; skip if you already have a key
</p>
<p className="text-white">$ estela create-api-token</p>
<p className="text-estela-black-low">Opening your browser&hellip;</p>
<p className="text-estela-black-low">&nbsp;</p>
<p className="text-white">$ estela set-token</p>
<p className="text-estela-black-low">
Paste your API key: &bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;
</p>
<p className="text-white">Logged in as {AuthService.getUserUsername()}</p>
<p className="text-estela-black-low">&nbsp;</p>
<p className="break-words text-white">$ estela init {this.projectId}</p>
<p className="text-white">$ estela deploy</p>
</div>
Expand Down
6 changes: 3 additions & 3 deletions estela-web/src/pages/LoginPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -38,7 +38,7 @@ export class LoginPage extends Component<unknown, LoginState> {
if (AuthService.getUserRole() && updateRole) {
updateRole(AuthService.getUserRole() ?? "");
}
history.push("/projects");
history.push(safeNextPath(window.location.search));
}
}

Expand Down Expand Up @@ -76,7 +76,7 @@ export class LoginPage extends Component<unknown, LoginState> {
updateEmail(response.user.email ?? "");
}
this.setState({ loading: false });
history.push("/projects");
history.push(safeNextPath(window.location.search));
},
(error: unknown) => {
handleInvalidDataError(error);
Expand Down
37 changes: 34 additions & 3 deletions estela-web/src/pages/SettingsApiKeysPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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.",
Expand All @@ -62,6 +66,7 @@ interface ApiKeysPageState {
newName: string;
newScopes: string[];
newDuration: ApiKeyCreateExpiresInDaysEnum;
forCli: boolean;
createdKey: string | null;
revoking: number | null;
}
Expand All @@ -75,6 +80,7 @@ export class SettingsApiKeysPage extends Component<unknown, ApiKeysPageState> {
newName: "",
newScopes: [],
newDuration: ApiKeyCreateExpiresInDaysEnum._90,
forCli: false,
createdKey: null,
revoking: null,
};
Expand All @@ -83,6 +89,17 @@ export class SettingsApiKeysPage extends Component<unknown, ApiKeysPageState> {

async componentDidMount(): Promise<void> {
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<void> => {
Expand Down Expand Up @@ -157,6 +174,7 @@ export class SettingsApiKeysPage extends Component<unknown, ApiKeysPageState> {
openCreateModal = (): void => {
this.setState({
createModal: true,
forCli: false,
newName: "",
newScopes: [],
newDuration: ApiKeyCreateExpiresInDaysEnum._90,
Expand Down Expand Up @@ -245,7 +263,7 @@ export class SettingsApiKeysPage extends Component<unknown, ApiKeysPageState> {
];

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 <Spin />;

Expand Down Expand Up @@ -284,7 +302,11 @@ export class SettingsApiKeysPage extends Component<unknown, ApiKeysPageState> {
style={{ overflow: "hidden", padding: 0 }}
open={createModal}
width={700}
title={<p className="text-xl text-center font-normal">NEW API KEY</p>}
title={
<p className="text-xl text-center font-normal">
{forCli ? "NEW KEY FOR ESTELA-CLI" : "NEW API KEY"}
</p>
}
footer={null}
onCancel={() => this.setState({ createModal: false })}
>
Expand Down Expand Up @@ -386,6 +408,15 @@ export class SettingsApiKeysPage extends Component<unknown, ApiKeysPageState> {
Copy
</Button>
</Row>
{forCli && (
<Row className="mt-4">
<Text className="text-estela-black-medium text-sm">
Copy the key above, then run{" "}
<span className="font-courier text-estela-black-full">estela set-token</span> and paste
it when asked.
</Text>
</Row>
)}
<Row className="flow-root mt-6">
<div className="flex justify-end w-full">
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,6 @@ models/UsageRecord.ts
models/User.ts
models/UserDetail.ts
models/UserProfile.ts
models/WhoAmI.ts
models/index.ts
runtime.ts
32 changes: 32 additions & 0 deletions estela-web/src/services/api/generated-api/apis/ApiApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ import {
UserProfile,
UserProfileFromJSON,
UserProfileToJSON,
WhoAmI,
WhoAmIFromJSON,
WhoAmIToJSON,
} from '../models';

export interface ApiAccountApiKeysCreateRequest {
Expand Down Expand Up @@ -1127,6 +1130,35 @@ export class ApiApi extends runtime.BaseAPI {
return await response.value();
}

/**
* Who the caller is. An API key carries no username, so this is how a program finds out which account it is acting as.
*/
async apiAuthWhoamiRaw(): Promise<runtime.ApiResponse<WhoAmI>> {
const queryParameters: any = {};

const headerParameters: runtime.HTTPHeaders = {};

if (this.configuration && (this.configuration.username !== undefined || this.configuration.password !== undefined)) {
headerParameters["Authorization"] = "Basic " + btoa(this.configuration.username + ":" + this.configuration.password);
}
const response = await this.request({
path: `/api/auth/whoami`,
method: 'GET',
headers: headerParameters,
query: queryParameters,
});

return new runtime.JSONApiResponse(response, (jsonValue) => WhoAmIFromJSON(jsonValue));
}

/**
* Who the caller is. An API key carries no username, so this is how a program finds out which account it is acting as.
*/
async apiAuthWhoami(): Promise<WhoAmI> {
const response = await this.apiAuthWhoamiRaw();
return await response.value();
}

/**
*/
async apiNotificationsCreateRaw(requestParameters: ApiNotificationsCreateRequest): Promise<runtime.ApiResponse<Notification>> {
Expand Down
Loading
Loading