Permission framework docs for plugin authors (#9702)

* Permission framework docs: plugin adopters

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Add item to sidebar

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/permission-framework-for-plugin-authors.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Apply suggestions from code review

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Co-authored-by: Joon Park <joonp@spotify.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Apply suggestions from code review

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Docs improvements

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Add todo-list plugins

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Refactor structure and minor changes

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Fix links

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Update docs/permission/plugin-authors/setup-for-the-tutorial.md

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Apply suggestions from code review

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* minor tutorial fixes

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Fully implement permission rule

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Add todos for missing content

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Improve minified script

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* More todos

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

* Fix rules exports

Signed-off-by: Vincenzo Scamporlino <me@vinzscam.dev>

Co-authored-by: MT Lewis <mtlewis@users.noreply.github.com>
Co-authored-by: Joon Park <joonp@spotify.com>
This commit is contained in:
Vincenzo Scamporlino
2022-03-03 11:55:43 +01:00
committed by Joon Park
parent f6b2566411
commit 23fc22c6dd
31 changed files with 1476 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
# Plugins contributions
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
@@ -0,0 +1,9 @@
# todo-list
Welcome to the todo-list backend plugin!
_This plugin was created through the Backstage CLI_
## Getting started
// TODO(vinzscam): replace this
@@ -0,0 +1,45 @@
{
"name": "@internal/plugin-todo-list-backend",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.10.6",
"@backstage/config": "^0.1.13",
"@backstage/errors": "^0.2.0",
"@backstage/plugin-auth-node": "^0.1.2",
"@types/express": "*",
"cross-fetch": "^3.0.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"uuid": "^8.3.2",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.13.1",
"@types/supertest": "^2.0.8",
"@types/uuid": "^8.3.4",
"msw": "^0.35.0",
"supertest": "^4.0.2"
},
"files": [
"dist"
]
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './service/router';
@@ -0,0 +1,33 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,47 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { IdentityClient } from '@backstage/plugin-auth-node';
import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
identity: {} as IdentityClient,
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /health', () => {
it('returns ok', async () => {
const response = await request(app).get('/health');
expect(response.status).toEqual(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
});
@@ -0,0 +1,86 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { errorHandler } from '@backstage/backend-common';
import { InputError } from '@backstage/errors';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import {
IdentityClient,
getBearerTokenFromAuthorizationHeader,
} from '@backstage/plugin-auth-node';
import { add, getAll, update } from './todos';
export interface RouterOptions {
logger: Logger;
identity: IdentityClient;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, identity } = options;
const router = Router();
router.use(express.json());
router.get('/health', (_, response) => {
logger.info('PONG!');
response.send({ status: 'ok' });
});
router.get('/todos', async (req, res) => {
res.json(getAll());
});
router.post('/todos', async (req, res) => {
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
let author: string | undefined = undefined;
const user = token ? await identity.authenticate(token) : undefined;
author = user?.identity.userEntityRef;
if (!isTodoCreateRequest(req.body)) {
throw new InputError('Invalid payload');
}
const todo = add({ title: req.body.title, author });
res.json(todo);
});
router.put('/todos', async (req, res) => {
if (!isTodoUpdateRequest(req.body)) {
throw new InputError('Invalid payload');
}
res.json(update(req.body));
});
router.use(errorHandler());
return router;
}
function isTodoCreateRequest(request: any): request is { title: string } {
return typeof request?.title === 'string';
}
function isTodoUpdateRequest(
request: any,
): request is { title: string; id: string } {
return typeof request?.id === 'string' && isTodoCreateRequest(request);
}
@@ -0,0 +1,61 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createServiceBuilder,
loadBackendConfig,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { IdentityClient } from '@backstage/plugin-auth-node';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'todo-list-backend' });
logger.debug('Starting application server...');
const config = await loadBackendConfig({ logger, argv: process.argv });
const discovery = SingleHostDiscovery.fromConfig(config);
const router = await createRouter({
logger,
identity: new IdentityClient({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
}),
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/todo-list', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -0,0 +1,64 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { v4 as uuid } from 'uuid';
import { NotFoundError } from '@backstage/errors';
export type Todo = {
title: string;
author?: string;
id: string;
timestamp: number;
};
export type TodoFilter = Parameters<Array<Todo>['filter']>[0];
const todos: { [key: string]: Todo } = {};
export function add(todo: Omit<Todo, 'id' | 'timestamp'>) {
const id = uuid();
const obj: Todo = { ...todo, id, timestamp: Date.now() };
todos[id] = obj;
return obj;
}
export function getTodo(id: string) {
return todos[id];
}
export function update({ id, title }: { id: string; title: string }) {
let todo = todos[id];
if (!todo) {
throw new NotFoundError('Item not found');
}
todo = { ...todo, title, timestamp: Date.now() };
todos[id] = todo;
return todo;
}
export function getAll(filter?: TodoFilter) {
let values = Object.values(todos);
if (filter) {
values = values.filter(filter);
}
return values.sort((a, b) => b.timestamp - a.timestamp);
}
// prepopulate the db
add({ title: 'just a note' });
add({ title: 'another note' });
@@ -0,0 +1,17 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
rules: {
'import/no-extraneous-dependencies': [
'error',
{
devDependencies: true,
optionalDependencies: true,
peerDependencies: true,
bundledDependencies: true,
},
],
},
};
+9
View File
@@ -0,0 +1,9 @@
# todo-list
Welcome to the todo-list plugin!
_This plugin was created through the Backstage CLI_
## Getting started
// TODO(vinzscam): replace this
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@internal/plugin-todo-list",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core-components": "^0.8.7",
"@backstage/core-plugin-api": "^0.6.0",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"react-use": "^17.2.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.13.1",
"@backstage/core-app-api": "^0.5.2",
"@backstage/dev-utils": "^0.2.12",
"@backstage/test-utils": "^0.2.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "*",
"@types/node": "*",
"msw": "^0.35.0"
},
"files": [
"dist"
]
}
@@ -0,0 +1,88 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Table, TableColumn, Progress } from '@backstage/core-components';
import Alert from '@material-ui/lab/Alert';
import useAsync from 'react-use/lib/useAsync';
import {
discoveryApiRef,
fetchApiRef,
useApi,
} from '@backstage/core-plugin-api';
import { Button } from '@material-ui/core';
export type Todo = {
title: string;
id: string;
author?: string;
timestamp: number;
};
type TodosTableProps = {
todos: Todo[];
onEdit(todo: Todo): any;
};
export const TodoList = ({ onEdit }: { onEdit(todo: Todo): any }) => {
const discoveryApi = useApi(discoveryApiRef);
const { fetch } = useApi(fetchApiRef);
const { value, loading, error } = useAsync(async (): Promise<Todo[]> => {
const response = await fetch(
`${await discoveryApi.getBaseUrl('todolist')}/todos`,
);
return response.json();
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
return <TodosTable todos={value || []} onEdit={onEdit} />;
};
export function TodosTable({ todos, onEdit }: TodosTableProps) {
const columns: TableColumn<Todo>[] = [
{ title: 'Title', field: 'title' },
{ title: 'Author', field: 'author' },
{
title: 'Last edit',
field: 'timestamp',
render: e => new Date(e.timestamp).toLocaleString(),
},
{
title: 'Action',
render: todo => {
return (
<Button variant="contained" onClick={() => onEdit(todo)}>
Edit
</Button>
);
},
},
];
return (
<Table
title="Todos"
options={{ search: false, paging: false }}
columns={columns}
data={todos}
/>
);
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TodoList } from './TodoList';
export type { Todo } from './TodoList';
@@ -0,0 +1,187 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useReducer, useRef, useState } from 'react';
import {
Typography,
Grid,
TextField,
Button,
Dialog,
Box,
DialogTitle,
DialogContent,
DialogActions,
} from '@material-ui/core';
import {
Header,
Page,
Content,
ContentHeader,
HeaderLabel,
SupportButton,
} from '@backstage/core-components';
import { Todo, TodoList } from '../TodoList';
import {
alertApiRef,
discoveryApiRef,
fetchApiRef,
useApi,
} from '@backstage/core-plugin-api';
export const TodoListPage = () => {
const discoveryApi = useApi(discoveryApiRef);
const { fetch } = useApi(fetchApiRef);
const alertApi = useApi(alertApiRef);
const title = useRef('');
const [key, refetchTodos] = useReducer(i => i + 1, 0);
const [editElement, setEdit] = useState<Todo | undefined>();
const handleAdd = async () => {
try {
const response = await fetch(
`${await discoveryApi.getBaseUrl('todolist')}/todos`,
{
method: 'POST',
body: JSON.stringify({ title: title.current }),
headers: {
'Content-Type': 'application/json',
},
},
);
if (!response.ok) {
const { error } = await response.json();
alertApi.post({
message: error.message,
severity: 'error',
});
return;
}
refetchTodos();
} catch (e: any) {
alertApi.post({ message: e.message, severity: 'error' });
}
};
const handleEdit = async (todo: Todo) => {
setEdit(undefined);
try {
const response = await fetch(
`${await discoveryApi.getBaseUrl('todolist')}/todos`,
{
method: 'PUT',
body: JSON.stringify({ title: todo.title, id: todo.id }),
headers: {
'Content-Type': 'application/json',
},
},
);
if (!response.ok) {
const { error } = await response.json();
alertApi.post({
message: error.message,
severity: 'error',
});
return;
}
refetchTodos();
} catch (e: any) {
alertApi.post({ message: e.message, severity: 'error' });
}
};
return (
<Page themeId="tool">
<Header
title="Welcome to todo-list!"
subtitle="Just a CRU todo list plugin"
>
<HeaderLabel label="Owner" value="Team X" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Todo List">
<SupportButton>A description of your plugin goes here.</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<Typography variant="body1">Add todo</Typography>
<Box
component="span"
alignItems="flex-end"
display="flex"
flexDirection="row"
>
<TextField
placeholder="Write something here..."
onChange={e => (title.current = e.target.value)}
/>
<Button variant="contained" onClick={handleAdd}>
Add
</Button>
</Box>
</Grid>
<Grid item>
<TodoList key={key} onEdit={setEdit} />
</Grid>
</Grid>
</Content>
{!!editElement && (
<EditModal
todo={editElement}
onSubmit={handleEdit}
onCancel={() => setEdit(undefined)}
/>
)}
</Page>
);
};
function EditModal({
todo,
onCancel,
onSubmit,
}: {
todo?: Todo;
onSubmit(todo: Todo): any;
onCancel(): any;
}) {
const title = useRef('');
return (
<Dialog open>
<DialogTitle id="form-dialog-title">Edit item</DialogTitle>
<DialogContent>
<TextField
placeholder="Write something here..."
defaultValue={todo?.title || ''}
onChange={e => (title.current = e.target.value)}
margin="dense"
fullWidth
/>
</DialogContent>
<DialogActions>
<Button color="primary" onClick={onCancel}>
Cancel
</Button>
<Button
onClick={() => onSubmit({ ...todo!, title: title.current })}
color="primary"
>
Save
</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TodoListPage } from './TodoListPage';
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { todoListPlugin, TodoListPage } from './plugin';
@@ -0,0 +1,22 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { todoListPlugin } from './plugin';
describe('todo-list', () => {
it('should export plugin', () => {
expect(todoListPlugin).toBeDefined();
});
});
+37
View File
@@ -0,0 +1,37 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createPlugin,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
export const todoListPlugin = createPlugin({
id: 'todolist',
routes: {
root: rootRouteRef,
},
});
export const TodoListPage = todoListPlugin.provide(
createRoutableExtension({
name: 'TodoListPage',
component: () =>
import('./components/TodoListPage').then(m => m.TodoListPage),
mountPoint: rootRouteRef,
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'todo-list',
});
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+1 -1
View File
@@ -38,4 +38,4 @@ The permission framework was designed with a few key properties in mind:
See the "[getting started](./getting-started.md)" permission documentation for Backstage integrators.
If you are a plugin author, see the permission [documentation for plugin authors](../plugins/permissions/index.md) on how to integrate permissions into your plugin.
If you are a plugin author, see the permission [documentation for plugin authors](plugin-authors/setup-for-the-tutorial.md) on how to integrate permissions into your plugin.
@@ -0,0 +1,185 @@
---
id: authorize-the-create-endpoint
title: Authorize the create endpoint
description: Authorize the create endpoint
---
The first step we need to do in order to authorize the create endpoint, is to create a new `permission` inside our backend plugin.
// TODO(vinzscam): add links to the core concepts
Install the following module:
```
$ yarn workspace @internal/plugin-todo-list-backend add @backstage/plugin-permission-common
```
Let's create a new `permissions.ts` file under `plugins/todo-list-backend/src/service/permissions.ts` with the following content:
```typescript
import { Permission } from '@backstage/plugin-permission-common';
export const TODO_LIST_RESOURCE_TYPE = 'todo-item';
export const todosListCreate: Permission = {
name: 'todos.list.create',
attributes: {
action: 'create',
},
};
export const todosListUpdate: Permission = {
name: 'todos.list.update',
attributes: {
action: 'update',
},
resourceType: TODO_LIST_RESOURCE_TYPE,
};
export const todosListRead: Permission = {
name: 'todos.list.read',
attributes: {
action: 'read',
},
resourceType: TODO_LIST_RESOURCE_TYPE,
};
```
The file contains all the permissions that we are going to use in the next steps.
Let's authorize the create endpoint.
Authorizing this endpoint now means that the adopters could decide whether some users should be allowed or not allowed to perform the create operation.
Edit `plugins/todo-list-backend/src/service/router.ts`:
```diff
- import { InputError } from '@backstage/errors';
+ import { InputError, NotAllowedError } from '@backstage/errors';
import { add, getAll, getTodo, Todo, TodoFilter, update } from './todos';
+ import { PermissionAuthorizer, AuthorizeResult } from '@backstage/plugin-permission-common';
+ import { todosListCreate } from './permissions';
export interface RouterOptions {
logger: Logger;
identity: IdentityClient;
+ permissions: PermissionAuthorizer;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
- const { logger, identity } = options;
+ const { logger, identity, permissions } = options;
...
router.post('/todos', async (req, res) => {
const token = IdentityClient.getBearerToken(req.header('authorization'));
let author: string | undefined = undefined;
const user = token ? await identity.authenticate(token) : undefined;
author = user?.identity.userEntityRef;
+ const decision = (
+ await permissions.authorize([{ permission: todosListCreate }], {
+ token,
+ })
+ )[0];
+ if (decision.result === AuthorizeResult.DENY) {
+ throw new NotAllowedError('Unauthorized');
+ }
if (!isTodoCreateRequest(req.body)) {
throw new InputError('Invalid payload');
}
const todo = add({ title: req.body.title, author });
res.json(todo);
});
```
Pass the `permissions` object to the plugin in `packages/backend/src/plugins/todolist.ts`:
```diff
import { IdentityClient } from '@backstage/plugin-auth-backend';
import { createRouter } from '@internal/plugin-todo-list-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
discovery,
+ permissions,
}: PluginEnvironment): Promise<Router> {
return await createRouter({
logger,
identity: new IdentityClient({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
}),
+ permissions,
});
}
```
That's it! Now your plugin is fully configured.
Let's try to test the logic by denying the permission.
### Test the authorized create endpoint
In order to test the logic above, the integrators of your backstage instance need to deny change their permission policy in `packages/backend/src/plugins/permission.ts`:
```diff
- import { IdentityClient } from '@backstage/plugin-auth-node';
+ import { BackstageIdentityResponse, IdentityClient } from '@backstage/plugin-auth-node';
import {
PermissionPolicy,
+ PolicyAuthorizeQuery,
PolicyDecision,
} from '@backstage/plugin-permission-node';
- class AllowAllPermissionPolicy implements PermissionPolicy
+ class MyPermissionPolicy implements PermissionPolicy {
- async handle(): Promise<PolicyDecision> {
+ async handle(
+ request: PolicyAuthorizeQuery,
+ user?: BackstageIdentityResponse,
+ ): Promise<PolicyDecision> {
+ if (request.permission.name === 'todos.list.create') {
+ return {
+ result: AuthorizeResult.DENY,
+ };
+ }
return {
result: AuthorizeResult.ALLOW,
};
}
export default async function createPlugin({
discovery,
logger,
}: PluginEnvironment) {
- const policy = new AllowAllPermissionPolicy();
+ const policy = new MyPermissionPolicy();
...
}
```
Now the frontend should show an error whenever you try to create a new Todo item.
Let's flip the result back to `ALLOW`:
```diff
if (request.permission.attributes.action === 'create') {
return {
- result: AuthorizeResult.DENY,
+ result: AuthorizeResult.ALLOW,
};
```
now the create endpoint should be enabled again.
@@ -0,0 +1,118 @@
---
id: authorize-the-read-endpoint
title: Authorize the read endpoint
description: Authorize the read endpoint
---
Authorizing `GET /todos` is similar to the update endpoint, in that it should be possible to authorize read access to todo entries based on their characteristics. When a `GET /todos` request is received, only the items that the user is permitted to see should be returned.
As in the previous case, the permission policy can't take the decision itself, meaning that a conditional decision should be returned.
However, this time rather than a single `resourceRef` we have a whole list of resources to authorize.
Potentially, something like this could be done:
```diff
router.get('/todos', async (req, res) => {
+ const token = IdentityClient.getBearerToken(req.header('authorization'));
- res.json(getAll())
+ const items = getAll();
+ const decisions = await permissions.authorize(
+ items.map(({ id }) => ({ permission: todosListRead, resourceRef: id })),
+ );
+ const filteredItems = decisions.filter(
+ decision => decision.result === AuthorizeResult.ALLOW,
+ );
+ res.json(filteredItems);
});
```
This should do the trick. However, this approach has a downside. It would force us to retrieve all the elements upfront and authorize them one by one, and mean that concerns like pagination that are handled by the data source today also need to move into the plugin.
To avoid this situation, the permissions framework has support for filtering items in the data source itself.
`plugins/todo-list-backend/src/service/router.ts`
```diff
- import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
+ import {
+ createPermissionIntegrationRouter,
+ createConditionTransformer,
+ ConditionTransformer,
+ } from '@backstage/plugin-permission-node';
import {
todosListCreate,
todosListUpdate,
+ todosListRead,
TODO_LIST_RESOURCE_TYPE,
} from './permissions';
router.get('/todos', async (req, res) => {
+ const token = getBearerTokenFromAuthorizationHeader(
+ req.header('authorization'),
+ );
+ const decision = (
+ await permissions.authorize([{ permission: todosListRead }], {
+ token,
+ })
+ )[0];
+ if (decision.result === AuthorizeResult.DENY) {
+ throw new NotAllowedError('Unauthorized');
+ }
+ if (decision.result === AuthorizeResult.CONDITIONAL) {
+ const conditionTransformer: ConditionTransformer<TodoFilter> =
+ createConditionTransformer(rules);
+ const filter = conditionTransformer(decision.conditions) as TodoFilter;
+ res.json(getAll(filter));
+ return;
+ }
res.json(getAll());
});
```
In this case, we are not passing any `resourceRef` when invoking `permissions.authorize()`.
Since there is no `resourceRef` and the permission policy is returning a conditional response, the permission framework can't make a decision
on its own and it's expecting the todo list backend's router to take care of this case.
Instead of authorizing every todo item one by one, we can implement this more efficiently.
If all the items were stored in a database, we could transform each permission result in the proper database query, letting the database do the filtering.
If this approach is not applicable, you can still use the result-by-result authorization approach mentioned at the beginning of the section.
Fortunately, our todo service is smart enough and lets us pass an optional `filter` function when invoking `getAll()`.
This is exactly what the `toQuery` field in the permission rule does.
In this particular example, the `isOwner` rule returns a function (expected by `getAll` method). But there is no constraint regarding the shape of the returned object: any type of data can be returned.
Now let's update our permission policy's handler to return a conditional result whenever a todosListCreate permission is received. We could reuse the same result as the update action:
```diff
if (request.permission.resourceType === 'todo-item') {
- if (request.permission.resourceType === 'todo-item') {
+ if (
+ request.permission.attributes.action === 'update' ||
+ request.permission.attributes.action === 'read'
+ ) {
return {
result: AuthorizeResult.CONDITIONAL,
pluginId: 'todolist',
resourceType: 'todo-item',
conditions: {
rule: 'IS_OWNER',
params: [user?.identity.userEntityRef],
},
};
}
}
```
Once the changes to the permission policy are saved, the UI should should show only the items you have created.
// TODO(vinzscam): add support for boolean operators
// TODO(vinzscam): add frontend documentation
@@ -0,0 +1,160 @@
---
id: authorize-the-update-endpoint
title: Authorize the update endpoint
description: Authorize the update endpoint
---
When performing updates (or other operations) on specific resources, the permissions framework allows for the decision to be based on characteristics of the resource itself. This means that it's possible to write policies that (for example) allow the operation for users that own a resource, and deny the operation otherwise.
// TODO(vinzscam): remind that the plugin used in this tutorial is bringing its own types to backstage.
// for plugins relying on external entities (like catalog entities) please follow [link] tutorial.
To start with, let's edit `plugins/todo-list-backend/src/service/router.ts` in a similar way as in the previous step.
```diff
- import { todosListCreate } from './permissions';
+ import { todosListCreate, todosListUpdate } from './permissions';
...
router.put('/todos', async (req, res) => {
+ const token = getBearerTokenFromAuthorizationHeader(
+ req.header('authorization'),
+ );
if (!isTodoUpdateRequest(req.body)) {
throw new InputError('Invalid payload');
}
+ const decision = (
+ await permissions.authorize(
+ [{ permission: todosListUpdate, resourceRef: req.body.id }],
+ {
+ token,
+ },
+ )
+ )[0];
- res.json(update(req.body));
+ if (decision.result === AuthorizeResult.ALLOW) {
+ res.json(update(req.body));
+ return;
+ }
+ throw new NotAllowedError('Unauthorized');
});
```
**Important:** Notice that we are passing an extra `resourceRef` object, containing the `id` of the todo we want to authorize.
This enables decisions based on characteristics of the resource, but it's important to note that to enable authorizing multiple resources at once, **the resourceRef is not passed to PermissionPolicy#handle**. Instead, policies must return a _conditional decision_.
Before diving into the extra steps needed for supporting conditional decisions, let's go back to the permission policy's handle function used by your adopters and try to authorize our new permission.
Let's edit `packages/backend/src/plugins/permission.ts`
```diff
if (request.permission.name === 'todos.list.create') {
return {
result: AuthorizeResult.DENY,
};
}
+ if (request.permission.resourceType === 'todo-item') {
+ if (request.permission.attributes.action === 'update') {
+ return {
+ result: AuthorizeResult.CONDITIONAL,
+ pluginId: 'todolist',
+ resourceType: 'todo-item',
+ conditions: {
+ rule: 'IS_OWNER',
+ params: [user?.identity.userEntityRef],
+ },
+ };
+ }
+ }
return {
result: AuthorizeResult.ALLOW,
};
```
This is what happens when a _Conditional Decision_ is returned. We are saying:
> Hey permission framework, I can't make a decision alone. Please go to the plugin with id `todolist` and ask it to apply these conditions.
Now if we try to edit an item from the UI, we should spot the following error in the backend's console:
```
backstage error Unexpected response from plugin upstream when applying conditions.
Expected 200 but got 404 - Not Found type=errorHandler stack=Error:
Unexpected response from plugin upstream when applying conditions. Expected 200 but got 404 - Not Found
```
This happens because our plugin should have exposed a specific endpoint, used by the permission framework to apply conditional decisions. The new endpoint should also be able to support some conditions. In our case, `IS_OWNER` is the only type of condition we want to support. You can add as many built-in conditions as you like to your plugin, and you can also allow Backstage integrators to supply more conditions when starting your backend if you want.
Install the missing module:
```
$ yarn workspace @internal/plugin-todo-list-backend add @backstage/plugin-permission-node
```
Create a new `plugins/todo-list-backend/src/service/rules.ts` file and append the following code:
```diff
+ import { makeCreatePermissionRule } from '@backstage/plugin-permission-node';
+ import { Todo, TodoFilter } from './todos';
+ const createTodoListPermissionRule = makeCreatePermissionRule<
+ Todo,
+ TodoFilter
+ >();
+ export const isOwner = createTodoListPermissionRule({
+ name: 'IS_OWNER',
+ description: 'Should allow only if the todo belongs to the user',
+ apply: (resource, userId) => {
+ return resource.author === userId;
+ },
+ toQuery: userId => {
+ return resource => resource.author === userId;
+ },
+ });
+ export const rules = { isOwner };
```
`makeCreatePermissionRule` is a helper used to ensure that rules created for this plugin use consistent types for the resource and query.
We have created a new `isOwner` rule, which is going to be automatically used by the permission framework whenever a conditional response is returned in response to an authorized request with an attached `resourceRef`.
Specifically, the `apply` function is used to understand whether the passed resource should be authorized or not.
Let's skip the `toQuery` function for now.
Now, let's create the new endpoint by editing `plugins/todo-list-backend/src/service/router.ts`:
```diff
+ import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
- import { todosListCreate, todosListUpdate } from './permissions';
+ import { todosListCreate, todosListUpdate, TODO_LIST_RESOURCE_TYPE } from './permissions';
+ import { rules } from './rules;
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, identity, permissions } = options;
+ const permissionIntegrationRouter = createPermissionIntegrationRouter({
+ getResources: async resourceRefs => {
+ return resourceRefs.map(getTodo);
+ },
+ resourceType: TODO_LIST_RESOURCE_TYPE,
+ rules: Object.values(rules),
+ });
const router = Router();
router.use(express.json());
+ router.use(permissionIntegrationRouter);
router.post('/todos', async (req, res) => {
```
To check that everything works as expected, you should see an error in the UI whenever you try to edit an item that wasnt created by you.
@@ -0,0 +1,126 @@
---
id: setup-for-the-tutorial
title: Permission framework for plugin authors
description: How to get started with the permission framework as a plugin author
---
The following tutorial is designed to help plugin authors add support for permissions to their plugins. We'll add support for permissions to example `todo-list` and `todo-list-backend` plugins, but the process should be similar for other plugins!
The rest of this page is focused on adding the `todo-list` and `todo-list-backend` plugins to your Backstage instance. If you want to add support for permissions to your own plugin instead, feel free to skip to the [next section](authorize-the-create-endpoint.md).
## Setup for the Tutorial
We will use a "Todo list" feature, composed of the `todo-list` and `todo-list-backend` plugins.
The source code is available here:
- [todo-list](https://github.com/backstage/backstage/blob/master/contrib/plugins/todo-list)
- [todo-list-backend](https://github.com/backstage/backstage/blob/master/contrib/plugins/todo-list-backend)
1. Copy-paste the two folders into the plugins folder of your backstage application repository or run the following script from the root of your backstage application:
```bash
$ node -e "const{UrlReaders}=require('@backstage/backend-common');\
const{ConfigReader}=require('@backstage/config');\
const winston=require('winston');const{outputFile}=require('fs-extra');\
const path=require('path');const logger=winston.createLogger({level:'info',});\
const config=new ConfigReader({});(async function execute(){\
const reader=UrlReaders.default({logger,config});const files=await reader.readTree(\
'https://github.com/backstage/backstage/tree/permission-docs\
/contrib/plugins');return Promise.all((await files.files()).map(async file=>\
file.path!=='README.md'&&outputFile(path.join(__dirname,'plugins',file.path),\
await file.content(),),),).catch(console.log)})()"
```
Your application structure should look something like this:
![backstage application files structure](../../assets/permission/permission-tutorial-backstage-application-initial-structure.png)
2. add the frontend and backend plugins as dependencies of your Backstage app and backend respectively:
```
$ yarn workspace backend add @internal/plugin-todo-list-backend@^1.0.0
$ yarn workspace app add @internal/plugin-todo-list@^1.0.0
```
3. Include the backend and frontend plugin in your application:
Create a new `packages/backend/src/plugins/todolist.ts` with the following content:
```javascript
import { IdentityClient } from '@backstage/plugin-auth-node';
import { createRouter } from '@internal/plugin-todo-list-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
discovery,
}: PluginEnvironment): Promise<Router> {
return await createRouter({
logger,
identity: IdentityClient.create({
discovery,
issuer: await discovery.getExternalBaseUrl('auth'),
}),
});
}
```
Apply the following changes to `packages/backend/src/index.ts`:
```diff
import techdocs from './plugins/techdocs';
+ import todoList from './plugins/todolist';
import search from './plugins/search';
...
const searchEnv = useHotMemoize(module, () => createEnv('search'));
const appEnv = useHotMemoize(module, () => createEnv('app'));
+ const todoListEnv = useHotMemoize(module, () => createEnv('todolist'));
...
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/search', await search(searchEnv));
apiRouter.use('/permission', await permission(permissionEnv));
+ apiRouter.use('/todolist', await todoList(todoListEnv));
// Add backends ABOVE this line; this 404 handler is the catch-all fallback
apiRouter.use(notFoundHandler());
```
Apply the following changes to `packages/app/src/App.ts`:
```diff
+ import { TodoListPage } from '@internal/plugin-todo-list';
...
<Route path="/search" element={<SearchPage />}>
{searchPage}
</Route>
<Route path="/settings" element={<UserSettingsPage />} />
+ <Route path="/todo-list" element={<TodoListPage />} />
</FlatRoutes>
```
Now if you start your application you should be able to reach the `/todo-list` page:
![Todo List plugin page](../../assets/permission/permission-todo-list-page.png)
---
// TODO(vinzscam): split the tutorial in two parts:
// - plugins relying on external entities
// - plugins owning their own entities
## Integrate the new plugin
If you play with the UI, you will notice that it is possible to perform a few actions:
- create a new todo item (`POST /todos`)
- view todo items (`GET /todos`)
- edit an existing todo item (`PUT /todos`)
Let's try to bring authorization on top of each one of them.
+11 -1
View File
@@ -265,7 +265,17 @@
"permission/overview",
"permission/getting-started",
"permission/writing-a-policy",
"permission/concepts"
"permission/concepts",
{
"type": "subcategory",
"label": "Permission framework for plugin authors",
"ids": [
"permission/plugin-authors/setup-for-the-tutorial",
"permission/plugin-authors/authorize-the-create-endpoint",
"permission/plugin-authors/authorize-the-update-endpoint",
"permission/plugin-authors/authorize-the-read-endpoint"
]
}
],
"Deployment": [
"deployment/index",