@fntio/infra-client-plugin-example 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +93 -0
- package/README.md +347 -2
- package/dist/handler.d.ts +10 -0
- package/dist/handler.js +93 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +112 -0
- package/dist/parameters.d.ts +3 -0
- package/dist/parameters.js +50 -0
- package/dist/scope.d.ts +25 -0
- package/dist/scope.js +33 -0
- package/lambda-dist/index.js +254 -0
- package/package.json +82 -4
- package/src/handler.ts +111 -0
- package/src/index.ts +96 -0
- package/src/parameters.ts +54 -0
- package/src/scope.ts +46 -0
- package/web/app.css +16 -0
- package/web/app.ts +296 -0
- package/web/index.html +16 -0
- package/web-dist/app.css +16 -0
- package/web-dist/app.js +71 -0
- package/web-dist/index.html +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Elastic License 2.0
|
|
2
|
+
|
|
3
|
+
URL: https://www.elastic.co/licensing/elastic-license
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
By using the software, you agree to all of the terms and conditions below.
|
|
8
|
+
|
|
9
|
+
## Copyright License
|
|
10
|
+
|
|
11
|
+
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
|
12
|
+
non-sublicensable, non-transferable license to use, copy, distribute, make
|
|
13
|
+
available, and prepare derivative works of the software, in each case subject to
|
|
14
|
+
the limitations and conditions below.
|
|
15
|
+
|
|
16
|
+
## Limitations
|
|
17
|
+
|
|
18
|
+
You may not provide the software to third parties as a hosted or managed
|
|
19
|
+
service, where the service provides users with access to any substantial set of
|
|
20
|
+
the features or functionality of the software.
|
|
21
|
+
|
|
22
|
+
You may not move, change, disable, or circumvent the license key functionality
|
|
23
|
+
in the software, and you may not remove or obscure any functionality in the
|
|
24
|
+
software that is protected by the license key.
|
|
25
|
+
|
|
26
|
+
You may not alter, remove, or obscure any licensing, copyright, or other notices
|
|
27
|
+
of the licensor in the software. Any use of the licensor’s trademarks is subject
|
|
28
|
+
to applicable law.
|
|
29
|
+
|
|
30
|
+
## Patents
|
|
31
|
+
|
|
32
|
+
The licensor grants you a license, under any patent claims the licensor can
|
|
33
|
+
license, or becomes able to license, to make, have made, use, sell, offer for
|
|
34
|
+
sale, import and have imported the software, in each case subject to the
|
|
35
|
+
limitations and conditions in this license. This license does not cover any
|
|
36
|
+
patent claims that you cause to be infringed by modifications or additions to
|
|
37
|
+
the software. If you or your company make any written claim that the software
|
|
38
|
+
infringes or contributes to infringement of any patent, your patent license for
|
|
39
|
+
the software granted under these terms ends immediately. If your company makes
|
|
40
|
+
such a claim, your patent license ends immediately for work on behalf of your
|
|
41
|
+
company.
|
|
42
|
+
|
|
43
|
+
## Notices
|
|
44
|
+
|
|
45
|
+
You must ensure that anyone who gets a copy of any part of the software from you
|
|
46
|
+
also gets a copy of these terms.
|
|
47
|
+
|
|
48
|
+
If you modify the software, you must include in any modified copies of the
|
|
49
|
+
software prominent notices stating that you have modified the software.
|
|
50
|
+
|
|
51
|
+
## No Other Rights
|
|
52
|
+
|
|
53
|
+
These terms do not imply any licenses other than those expressly granted in
|
|
54
|
+
these terms.
|
|
55
|
+
|
|
56
|
+
## Termination
|
|
57
|
+
|
|
58
|
+
If you use the software in violation of these terms, such use is not licensed,
|
|
59
|
+
and your licenses will automatically terminate. If the licensor provides you
|
|
60
|
+
with a notice of your violation, and you cease all violation of this license no
|
|
61
|
+
later than 30 days after you receive that notice, your licenses will be
|
|
62
|
+
reinstated retroactively. However, if you violate these terms after such
|
|
63
|
+
reinstatement, any additional violation of these terms will cause your licenses
|
|
64
|
+
to terminate automatically and permanently.
|
|
65
|
+
|
|
66
|
+
## No Liability
|
|
67
|
+
|
|
68
|
+
*As far as the law allows, the software comes as is, without any warranty or
|
|
69
|
+
condition, and the licensor will not be liable to you for any damages arising
|
|
70
|
+
out of these terms or the use or nature of the software, under any kind of
|
|
71
|
+
legal claim.*
|
|
72
|
+
|
|
73
|
+
## Definitions
|
|
74
|
+
|
|
75
|
+
The **licensor** is the entity offering these terms, and the **software** is the
|
|
76
|
+
software the licensor makes available under these terms, including any portion
|
|
77
|
+
of it.
|
|
78
|
+
|
|
79
|
+
**you** refers to the individual or entity agreeing to these terms.
|
|
80
|
+
|
|
81
|
+
**your company** is any legal entity, sole proprietorship, or other kind of
|
|
82
|
+
organization that you work for, plus all organizations that have control over,
|
|
83
|
+
are under the control of, or are under common control with that
|
|
84
|
+
organization. **control** means ownership of substantially all the assets of an
|
|
85
|
+
entity, or the power to direct its management and policies by vote, contract, or
|
|
86
|
+
otherwise. Control can be direct or indirect.
|
|
87
|
+
|
|
88
|
+
**your licenses** are all the licenses granted to you for the software under
|
|
89
|
+
these terms.
|
|
90
|
+
|
|
91
|
+
**use** means anything you do with the software requiring one of your licenses.
|
|
92
|
+
|
|
93
|
+
**trademark** means trademarks, service marks, and similar rights.
|
package/README.md
CHANGED
|
@@ -1,3 +1,348 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @fntio/infra-client-plugin-example
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The reference plugin, a scoped **AWS Systems Manager Parameter Store** viewer and editor, and the
|
|
4
|
+
step-by-step guide to getting a plugin of your own running. The example is deliberately small enough
|
|
5
|
+
to read in one sitting; the guide below is the order of operations, with the code each step needs.
|
|
6
|
+
|
|
7
|
+
A plugin is a small app of your own inside your infra-client portal. The portal does sign-in,
|
|
8
|
+
accounts, permissions and theme. You write three things: a CDK registration, a Lambda, and a web
|
|
9
|
+
page. Then you deploy your CDK app as usual. Needs `@fntio/infra-client` 0.2.0 or later: the plugin
|
|
10
|
+
packages at 0.2.x and the portal must be on the same message protocol (see the SDK README,
|
|
11
|
+
"Versioning"), and a page built with the 0.1.x packages has to be rebuilt.
|
|
12
|
+
|
|
13
|
+
## How it fits together
|
|
14
|
+
|
|
15
|
+
| Who | What |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| Portal | Shows your page in an iframe on its own origin, filling the whole page beside the sidebar, and hands it a token, the AWS accounts the caller may use (each with its permissions) and the theme. The portal draws no header above it and tracks no selected account: your page draws its own title, and keeps its own choice of account, with a picker if it needs one, from `context.accounts()`. Your page runs as the signed-in user (see "Things that bite"). |
|
|
18
|
+
| **Your page** | Calls your own API at `/api/plugins/<id>/accounts/<account>/…` with that token. |
|
|
19
|
+
| Portal | Checks the token and the grant before your Lambda runs. No grant means your code is never called. |
|
|
20
|
+
| **Your Lambda** | Gets who is calling and for which account, does the work, answers JSON. |
|
|
21
|
+
| Admins | Grant `user` or `admin` on your plugin per account, on the same Users and Groups screens as roles. |
|
|
22
|
+
|
|
23
|
+
## The steps
|
|
24
|
+
|
|
25
|
+
In this order. Each one is checked on its own before the next.
|
|
26
|
+
|
|
27
|
+
### 1. Turn the feature on, with no plugin yet
|
|
28
|
+
|
|
29
|
+
In your deployment's config, then deploy the hub stacks as you normally do.
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// config/project.ts
|
|
33
|
+
plugins: { enabled: true },
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Check:** the portal still works, and a signed-in call to `GET /api/plugins` returns
|
|
37
|
+
`{"schemaVersion":1,"refreshAfterSeconds":60,"plugins":[]}`.
|
|
38
|
+
|
|
39
|
+
### 2. Get the plugin packages
|
|
40
|
+
|
|
41
|
+
They are not on npm yet. Every [GitHub Release](https://github.com/FNTIO/aws-infrastructure-client/releases)
|
|
42
|
+
from v0.1.18 on carries them beside the core tarball: download the four plugin `.tgz` files from the
|
|
43
|
+
Release your portal runs and install them next to your `@fntio/infra-client` one. The four plugin
|
|
44
|
+
packages share one version (0.2.0 today) and release together.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cd <your cdk project> && mkdir -p vendor
|
|
48
|
+
gh release download v<version your portal runs> --repo FNTIO/aws-infrastructure-client \
|
|
49
|
+
--pattern 'fntio-infra-client-plugin-*.tgz' --dir vendor
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Older Release, or no download access? Build the same files from the source at that tag:
|
|
53
|
+
`git clone https://github.com/FNTIO/aws-infrastructure-client && git checkout v<version> && npm ci && npm run pack:plugins`
|
|
54
|
+
writes them into `packs/`.
|
|
55
|
+
|
|
56
|
+
```jsonc
|
|
57
|
+
// package.json of your CDK project
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@fntio/infra-client": "file:vendor/fntio-infra-client-<version>.tgz",
|
|
60
|
+
"@fntio/infra-client-plugin-sdk": "file:vendor/fntio-infra-client-plugin-sdk-0.2.0.tgz",
|
|
61
|
+
"@fntio/infra-client-plugin-cdk": "file:vendor/fntio-infra-client-plugin-cdk-0.2.0.tgz",
|
|
62
|
+
"@fntio/infra-client-plugin-ui": "file:vendor/fntio-infra-client-plugin-ui-0.2.0.tgz"
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Optional, recommended:** also install `fntio-infra-client-plugin-example-0.2.0.tgz` and register
|
|
67
|
+
this example first. It is a finished, working plugin, so it proves your deploy, the grants and the
|
|
68
|
+
iframe before you write a line. Its tarball carries the built frontend and a prebundled Lambda, so
|
|
69
|
+
your synth runs no bundler and needs no Docker.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { addAccessPortal } from "@fntio/infra-client";
|
|
73
|
+
import { parametersPlugin } from "@fntio/infra-client-plugin-example";
|
|
74
|
+
|
|
75
|
+
addAccessPortal(app, {
|
|
76
|
+
project: PROJECT,
|
|
77
|
+
resourcePrefix: RESOURCE_PREFIX,
|
|
78
|
+
plugins: [
|
|
79
|
+
parametersPlugin({
|
|
80
|
+
accountNumber: "111111111111",
|
|
81
|
+
region: "eu-central-1",
|
|
82
|
+
prefixes: ["/myapp/prod"],
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 3. Write the registration
|
|
89
|
+
|
|
90
|
+
One file in your CDK project. It declares what the plugin is: its id, the accounts it serves, the
|
|
91
|
+
two permission levels, the sidebar entries, and the routes with the Lambda that answers them. Copy
|
|
92
|
+
this package's `src/index.ts` and change the manifest; the pieces you will replace are the routes,
|
|
93
|
+
the handler and the frontend, and the shape around them is the same for every plugin.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// lib/my-plugin.ts
|
|
97
|
+
import * as path from "node:path";
|
|
98
|
+
import * as lambda from "aws-cdk-lib/aws-lambda";
|
|
99
|
+
import { definePlugin } from "@fntio/infra-client-plugin-cdk";
|
|
100
|
+
|
|
101
|
+
export const myPlugin = definePlugin({
|
|
102
|
+
schemaVersion: 1,
|
|
103
|
+
hostApiVersion: 1,
|
|
104
|
+
id: "parameters", // lowercase, digits, hyphens. Used in every URL and grant.
|
|
105
|
+
displayName: "Application parameters",
|
|
106
|
+
version: "1.0.0",
|
|
107
|
+
enabled: true,
|
|
108
|
+
accounts: [{ accountNumber: "111111111111" }],
|
|
109
|
+
permissions: [
|
|
110
|
+
{ id: "user", label: "View parameters" },
|
|
111
|
+
{ id: "admin", label: "Edit parameters" }, // admin includes user when both are declared
|
|
112
|
+
],
|
|
113
|
+
ui: {
|
|
114
|
+
assets: path.join(__dirname, "../web-dist"), // your built web page; must exist at synth
|
|
115
|
+
user: { label: "Parameters", entry: "index.html#/user" },
|
|
116
|
+
admin: { label: "Parameters", entry: "index.html#/admin" },
|
|
117
|
+
},
|
|
118
|
+
backend: {
|
|
119
|
+
handlers: {
|
|
120
|
+
api: (scope) => new lambda.Function(scope, "MyPluginApi", {
|
|
121
|
+
code: lambda.Code.fromAsset(path.join(__dirname, "../lambda-dist")),
|
|
122
|
+
handler: "index.handler",
|
|
123
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
routes: [ // paths are relative to the account; {name} is a single segment
|
|
127
|
+
{ id: "list", method: "GET", path: "/parameters", permission: "user", handlerKey: "api" },
|
|
128
|
+
{ id: "update", method: "PUT", path: "/parameters/{name}", permission: "admin", handlerKey: "api" },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Give that Lambda an IAM policy for exactly what it may touch. The portal decides who may call a
|
|
135
|
+
route; your policy decides what the code may do.
|
|
136
|
+
|
|
137
|
+
### 4. Write the Lambda
|
|
138
|
+
|
|
139
|
+
By the time it runs, the portal has already checked the caller. Read who and which account from
|
|
140
|
+
the context the portal attached, never from the request itself.
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// src/handler.ts, bundled to lambda-dist/index.js
|
|
144
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
145
|
+
import { createPluginLogger, pluginAuthorizerContextOf, pluginRoutePath }
|
|
146
|
+
from "@fntio/infra-client-plugin-sdk/backend";
|
|
147
|
+
|
|
148
|
+
export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> {
|
|
149
|
+
const ctx = pluginAuthorizerContextOf(event); // who, which account, which route. Throws if missing.
|
|
150
|
+
const log = createPluginLogger(ctx);
|
|
151
|
+
const route = pluginRoutePath(event.path, ctx); // "/parameters" or "/parameters/db-url"
|
|
152
|
+
|
|
153
|
+
if (event.httpMethod === "GET" && route === "/parameters") {
|
|
154
|
+
const items = await listParameters(ctx.accountNumber); // your code
|
|
155
|
+
log.info("listed", { count: items.length });
|
|
156
|
+
return json(200, { items });
|
|
157
|
+
}
|
|
158
|
+
if (event.httpMethod === "PUT" && event.pathParameters?.name) {
|
|
159
|
+
// ... validate, write, return 200. Business failures are 400/404/409, never 401.
|
|
160
|
+
}
|
|
161
|
+
return json(404, { message: "Unknown route" });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const json = (statusCode: number, body: unknown): APIGatewayProxyResult => ({
|
|
165
|
+
statusCode, headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Bundle it the way your project already bundles Lambdas (esbuild, `NodejsFunction`, whatever you
|
|
170
|
+
use). The Lambda runs in the hub account under the role you give it.
|
|
171
|
+
|
|
172
|
+
### 5. Write the web page
|
|
173
|
+
|
|
174
|
+
A normal web bundle, any framework or none. It never logs in and never stores a token. It asks the
|
|
175
|
+
portal to connect, then calls your API through the SDK.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// web/app.ts, bundled to web-dist/ next to index.html
|
|
179
|
+
import { connectToInfraClient } from "@fntio/infra-client-plugin-sdk/browser";
|
|
180
|
+
import { applyPluginUi } from "@fntio/infra-client-plugin-ui";
|
|
181
|
+
|
|
182
|
+
applyPluginUi(document); // the portal's look: ic-btn, ic-table, ic-card classes
|
|
183
|
+
const portal = await connectToInfraClient(); // waits for the portal; also applies its theme
|
|
184
|
+
|
|
185
|
+
let chosen: string | undefined; // which account the page works in is its own state
|
|
186
|
+
|
|
187
|
+
async function render() {
|
|
188
|
+
const accounts = portal.accounts(); // every account the caller may use here
|
|
189
|
+
const account = accounts.find((a) => a.accountNumber === chosen) ?? accounts[0];
|
|
190
|
+
if (!account) return;
|
|
191
|
+
const { items } = await portal.api.get<{ items: unknown[] }>(`accounts/${account.accountNumber}/parameters`);
|
|
192
|
+
document.getElementById("app")!.textContent = `${items.length} parameters`;
|
|
193
|
+
}
|
|
194
|
+
portal.on("accessChanged", render); // their grant changed; the chosen account may be gone
|
|
195
|
+
portal.on("sessionEnded", () => { document.getElementById("app")!.textContent = "Signed out."; });
|
|
196
|
+
await render();
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`portal.api.get/post/put/delete` adds the token for you, only for paths under your plugin's API
|
|
200
|
+
root. If your page is Angular, `createPluginInterceptor(portal)` from
|
|
201
|
+
`@fntio/infra-client-plugin-ui/angular` does the same for `HttpClient`.
|
|
202
|
+
|
|
203
|
+
### 6. Build, register, look at the diff
|
|
204
|
+
|
|
205
|
+
Build the web page and the Lambda first; the registration checks that `web-dist/index.html` exists
|
|
206
|
+
and fails synth if it does not. Then add the plugin where your portal is already composed.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// bin/app.ts
|
|
210
|
+
import { myPlugin } from "../lib/my-plugin";
|
|
211
|
+
|
|
212
|
+
addAccessPortal(app, { project: PROJECT, resourcePrefix: RESOURCE_PREFIX, plugins: [myPlugin] });
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
npm run build:web && npm run build:lambda # your own scripts
|
|
217
|
+
npx cdk diff
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**Expect in the diff:** your Lambda, its gateway methods, an authorizer (only for the first plugin),
|
|
221
|
+
one asset upload and one catalog record. Nothing else should change. Then `npx cdk deploy` the hub
|
|
222
|
+
stacks.
|
|
223
|
+
|
|
224
|
+
### 7. Grant yourself access and try it
|
|
225
|
+
|
|
226
|
+
Nobody has plugin access by default, not even admins. In the portal: **Users → your user → Plugins →
|
|
227
|
+
Grant plugin access**, pick the account and `admin`.
|
|
228
|
+
|
|
229
|
+
**Check three things:** the entry appears in the sidebar (within a minute, or reload) and the page
|
|
230
|
+
loads with your data. Calling a route with no token gives 401. A user without a grant gets 403 and
|
|
231
|
+
never reaches your Lambda. Every decision is a line in the authorizer's log group, and refused calls
|
|
232
|
+
show on the Activity page.
|
|
233
|
+
|
|
234
|
+
## Working on it locally
|
|
235
|
+
|
|
236
|
+
You do not need to deploy for every change to the page. The SDK ships a fake portal that speaks the
|
|
237
|
+
real protocol: serve your `web-dist` from any dev server, proxy `/api` to your deployed portal, and
|
|
238
|
+
drive the frame from a small harness page.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { createDevHost } from "@fntio/infra-client-plugin-sdk/fixtures";
|
|
242
|
+
|
|
243
|
+
const frame = document.querySelector("iframe")!;
|
|
244
|
+
const host = createDevHost({ frame, listenOn: window, origin: location.origin, entry: "admin" });
|
|
245
|
+
frame.src = "/plugin/index.html#/admin"; // create the host before setting src
|
|
246
|
+
// then: host.revoke(n), host.grant(a), host.expireSession(), host.setAppearance("dark"), host.signOut()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Its tokens are fakes no backend accepts, so calls that reach a real API still need a real sign-in
|
|
250
|
+
through the portal. Lambda changes need a deploy.
|
|
251
|
+
|
|
252
|
+
## Changing it later
|
|
253
|
+
|
|
254
|
+
- **Update:** change the code or the registration and redeploy. Open pages get a "new version,
|
|
255
|
+
reload" prompt.
|
|
256
|
+
- **Disable:** `enabled: false`. Hidden and refused, grants kept.
|
|
257
|
+
- **Remove:** delete it from `plugins: [...]` and redeploy. Grants are kept on purpose, so adding the
|
|
258
|
+
same id back restores them.
|
|
259
|
+
- **Turn the whole feature off:** remove every plugin in one deploy, then set `enabled: false` in a
|
|
260
|
+
second one.
|
|
261
|
+
|
|
262
|
+
## Things that bite
|
|
263
|
+
|
|
264
|
+
- **Your page runs as the signed-in user.** It is a same-origin iframe: nothing stops it from reading the
|
|
265
|
+
portal's tokens or calling core APIs as that user, and a compromised dependency in your bundle would
|
|
266
|
+
have the same power. The SDK's API-root pinning guards against mistakes, not against malice. Treat your
|
|
267
|
+
plugin's code and dependencies with the care of the portal itself; the portal's CSP on
|
|
268
|
+
`/plugin-assets/*` only lets your page load from and talk to the portal.
|
|
269
|
+
|
|
270
|
+
- **Build before synth, every time.** A missing `web-dist/index.html` fails `cdk synth`. Put the
|
|
271
|
+
build in your pipeline before the CDK step.
|
|
272
|
+
- **Ship a new route and the page that uses it together.** For a moment during a deploy the route
|
|
273
|
+
exists but the catalog does not know it yet, and calls get 403.
|
|
274
|
+
- **Never answer 401 from your Lambda.** The page's SDK treats 401 as "token expired" and retries
|
|
275
|
+
once. Use 400, 404 or 409 for your own errors.
|
|
276
|
+
- **One request is for one account.** Use `ctx.accountNumber`. If your Lambda serves only one
|
|
277
|
+
account, refuse any other with 409 instead of serving its own data.
|
|
278
|
+
- **Keep secrets out of logs.** The logger redacts obvious keys, but a value you log yourself is
|
|
279
|
+
logged.
|
|
280
|
+
- **Keep construct ids stable.** Renaming `"MyPluginApi"` replaces the Lambda.
|
|
281
|
+
|
|
282
|
+
## Read more
|
|
283
|
+
|
|
284
|
+
| Read | When |
|
|
285
|
+
| --- | --- |
|
|
286
|
+
| [plugin-sdk README](../infra-client-plugin-sdk/README.md) | Every SDK function for the page and the Lambda, the manifest rules, the dev host. |
|
|
287
|
+
| [plugin-cdk README](../infra-client-plugin-cdk/README.md) | `definePlugin` in full and the install, update, rollback, disable, remove lifecycle. |
|
|
288
|
+
| [plugin-ui README](../infra-client-plugin-ui/README.md) | The `ic-*` CSS classes and the Angular interceptor. |
|
|
289
|
+
| [Core README, Plugins](../infra-client/README.md#plugins) | Exactly what a deploy creates, how the authorizer decides, what a grant means. |
|
|
290
|
+
| [Wiki: Runbooks, Plugins](../../wiki/Runbooks-Plugins.md) | The admin's side: granting, disabling, removing. |
|
|
291
|
+
|
|
292
|
+
Using a coding agent? Two skills ship inside the packages: `build-infra-client-plugin` in the SDK
|
|
293
|
+
and `integrate-infra-client-plugin` in the CDK package. Point the agent at them and it works in your
|
|
294
|
+
repo's own conventions.
|
|
295
|
+
|
|
296
|
+
## About this example
|
|
297
|
+
|
|
298
|
+
What it demonstrates is the whole path: a manifest, two permission levels, routes behind the
|
|
299
|
+
portal's shared authorizer, a frontend in an iframe using the browser SDK, and a Lambda using the
|
|
300
|
+
backend SDK.
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
src/index.ts the registration a customer copies — manifest, handler factory, IAM
|
|
304
|
+
src/handler.ts the Lambda: authorizer context in, business rules out
|
|
305
|
+
src/scope.ts what this plugin will and will not touch — no AWS imports, so the registration loads without the Lambda's dependencies
|
|
306
|
+
src/parameters.ts the SSM-backed store, loaded only inside the Lambda
|
|
307
|
+
web/ the frontend, plain TypeScript, no framework
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The plugin's frontend must be built before synth, because `definePlugin` checks that the entry file
|
|
311
|
+
exists: `npm run build -w @fntio/infra-client-plugin-example`.
|
|
312
|
+
|
|
313
|
+
### What it does, and what it refuses
|
|
314
|
+
|
|
315
|
+
The user permission lists and reads parameters under the configured prefixes. The admin permission
|
|
316
|
+
also writes them, behind an explicit confirmation. Nothing else is reachable:
|
|
317
|
+
|
|
318
|
+
- **Prefixes bound it twice.** The handler refuses any name outside them, and the Lambda's IAM policy
|
|
319
|
+
is scoped to the same prefixes. Neither is the only thing standing in the way.
|
|
320
|
+
- **A prefix is a path, not a string match.** `/app/production` is not reachable through `/app/prod`,
|
|
321
|
+
and a `..` segment is refused outright.
|
|
322
|
+
- **One instance serves one account.** The portal authorizes a request for a particular account; if
|
|
323
|
+
that is not the account this instance was registered for, it answers 409 rather than quietly
|
|
324
|
+
serving its own. Two accounts means two registrations, each with its own function and policy.
|
|
325
|
+
- **Encrypted values stay encrypted.** A `SecureString` is fetched without decryption and returned as
|
|
326
|
+
metadata only, so a viewer sees that a secret exists without seeing it.
|
|
327
|
+
- **Values never reach a log.** The logger records that a parameter changed and its new version.
|
|
328
|
+
|
|
329
|
+
### Develop
|
|
330
|
+
|
|
331
|
+
```
|
|
332
|
+
npm install # at the repository root
|
|
333
|
+
npm run build -w @fntio/infra-client-plugin-example
|
|
334
|
+
npx tsc --noEmit && npm test
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Live testing uses disposable parameters only. Create a couple under a registered prefix, exercise the
|
|
338
|
+
plugin, then delete them:
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
aws ssm put-parameter --region <region> --overwrite --type String \
|
|
342
|
+
--name <prefix>/db/host --value pg.internal
|
|
343
|
+
aws ssm put-parameter --region <region> --overwrite --type SecureString \
|
|
344
|
+
--name <prefix>/db/password --value not-a-real-secret
|
|
345
|
+
aws ssm delete-parameters --region <region> --names <prefix>/db/host <prefix>/db/password
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The `SecureString` is there on purpose: the plugin lists it and never reads its value.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
2
|
+
import { ParameterStore } from "./scope";
|
|
3
|
+
export interface HandlerDeps {
|
|
4
|
+
readonly store: ParameterStore;
|
|
5
|
+
readonly prefixes: string[];
|
|
6
|
+
readonly homeAccount: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function handler(event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult>;
|
|
9
|
+
/** Exported for tests: the same path the Lambda takes, with its dependencies handed in. */
|
|
10
|
+
export declare function route(event: APIGatewayProxyEvent, d: HandlerDeps): Promise<APIGatewayProxyResult>;
|
package/dist/handler.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handler = handler;
|
|
4
|
+
exports.route = route;
|
|
5
|
+
const client_ssm_1 = require("@aws-sdk/client-ssm");
|
|
6
|
+
const backend_1 = require("@fntio/infra-client-plugin-sdk/backend");
|
|
7
|
+
const parameters_1 = require("./parameters");
|
|
8
|
+
const scope_1 = require("./scope");
|
|
9
|
+
// The plugin's three routes. Authentication and authorization already happened: the shared authorizer
|
|
10
|
+
// verified the caller, matched this request to a registered route, and attached the context below. What
|
|
11
|
+
// is left is the plugin's own business — which is why nothing here re-reads a permission or an account
|
|
12
|
+
// from the request body.
|
|
13
|
+
const ssm = new client_ssm_1.SSMClient({});
|
|
14
|
+
/** The account this function may touch. A route authorized for account A must never act on account B,
|
|
15
|
+
* so a mismatch is refused rather than quietly served from wherever the function happens to run. */
|
|
16
|
+
const HOME_ACCOUNT = process.env.PLUGIN_ACCOUNT_NUMBER ?? "";
|
|
17
|
+
const json = (statusCode, body) => ({
|
|
18
|
+
statusCode,
|
|
19
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
20
|
+
body: JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
function deps() {
|
|
23
|
+
return { store: (0, parameters_1.ssmParameterStore)(ssm), prefixes: (0, scope_1.configuredPrefixes)(), homeAccount: HOME_ACCOUNT };
|
|
24
|
+
}
|
|
25
|
+
async function handler(event) {
|
|
26
|
+
return route(event, deps());
|
|
27
|
+
}
|
|
28
|
+
/** Exported for tests: the same path the Lambda takes, with its dependencies handed in. */
|
|
29
|
+
async function route(event, d) {
|
|
30
|
+
let ctx;
|
|
31
|
+
try {
|
|
32
|
+
ctx = (0, backend_1.pluginAuthorizerContextOf)(event);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Only reachable if this function is invoked outside its gateway route; fail closed rather than
|
|
36
|
+
// guess who the caller is.
|
|
37
|
+
return json(500, { message: "This request did not come through the portal." });
|
|
38
|
+
}
|
|
39
|
+
const log = (0, backend_1.createPluginLogger)(ctx);
|
|
40
|
+
if (!d.prefixes.length) {
|
|
41
|
+
log.error("no parameter prefixes configured");
|
|
42
|
+
return json(500, { message: "This plugin has no parameter prefixes configured." });
|
|
43
|
+
}
|
|
44
|
+
if (ctx.accountNumber !== d.homeAccount) {
|
|
45
|
+
// The portal authorized an account this deployment of the plugin cannot serve. Reading the home
|
|
46
|
+
// account's parameters anyway would hand the caller another account's data.
|
|
47
|
+
log.error("authorized for an account this plugin does not serve", { requested: ctx.accountNumber });
|
|
48
|
+
return json(409, { message: `This plugin instance only serves account ${d.homeAccount}.` });
|
|
49
|
+
}
|
|
50
|
+
const path = (0, backend_1.pluginRoutePath)(event.path, ctx);
|
|
51
|
+
if (path === null)
|
|
52
|
+
return json(400, { message: "That path is outside this plugin." });
|
|
53
|
+
const method = event.httpMethod.toUpperCase();
|
|
54
|
+
const name = event.pathParameters?.["name"];
|
|
55
|
+
try {
|
|
56
|
+
if (method === "GET" && path === "/parameters") {
|
|
57
|
+
const parameters = await d.store.list(d.prefixes);
|
|
58
|
+
log.info("listed parameters", { count: parameters.length });
|
|
59
|
+
return json(200, { parameters, prefixes: d.prefixes });
|
|
60
|
+
}
|
|
61
|
+
if (method === "GET" && name) {
|
|
62
|
+
const decoded = decodeURIComponent(name);
|
|
63
|
+
(0, scope_1.assertInScope)(decoded, d.prefixes);
|
|
64
|
+
const parameter = await d.store.get(decoded);
|
|
65
|
+
log.info("read parameter", { name: decoded, secret: parameter.secret });
|
|
66
|
+
return json(200, { parameter });
|
|
67
|
+
}
|
|
68
|
+
if (method === "PUT" && name) {
|
|
69
|
+
const decoded = decodeURIComponent(name);
|
|
70
|
+
(0, scope_1.assertInScope)(decoded, d.prefixes);
|
|
71
|
+
const value = JSON.parse(event.body ?? "{}").value;
|
|
72
|
+
if (typeof value !== "string" || !value.length) {
|
|
73
|
+
return json(400, { message: "Send a non-empty string value." });
|
|
74
|
+
}
|
|
75
|
+
const version = await d.store.put(decoded, value);
|
|
76
|
+
// The value itself is never logged — only that it changed, and to which version.
|
|
77
|
+
log.info("updated parameter", { name: decoded, version });
|
|
78
|
+
return json(200, { name: decoded, version });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (err instanceof scope_1.ParameterScopeError) {
|
|
83
|
+
log.warn("refused an out-of-scope parameter", { error: err.message });
|
|
84
|
+
return json(400, { message: err.message });
|
|
85
|
+
}
|
|
86
|
+
if (err.name === "ParameterNotFound") {
|
|
87
|
+
return json(404, { message: "No such parameter." });
|
|
88
|
+
}
|
|
89
|
+
log.error("parameter operation failed", { error: err.message });
|
|
90
|
+
return json(502, { message: "The parameter store did not answer." });
|
|
91
|
+
}
|
|
92
|
+
return json(404, { message: `No such route: ${method} ${path}` });
|
|
93
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { PluginDefinition } from "@fntio/infra-client-plugin-cdk";
|
|
2
|
+
export interface ParametersPluginProps {
|
|
3
|
+
/** The account whose parameters this instance serves. The handler refuses any other, even if the
|
|
4
|
+
* portal authorizes one, so a second account means a second registration with its own function. */
|
|
5
|
+
readonly accountNumber: string;
|
|
6
|
+
/** SSM name prefixes the plugin may read and write. Nothing outside them is reachable — not by a
|
|
7
|
+
* crafted name, not by a `..` segment. Least privilege is enforced twice: here and in the IAM policy. */
|
|
8
|
+
readonly prefixes: readonly string[];
|
|
9
|
+
/** Defaults to the stack's region. */
|
|
10
|
+
readonly region?: string;
|
|
11
|
+
readonly version?: string;
|
|
12
|
+
/** Off by default would hide it from everyone; registrations are usually enabled. */
|
|
13
|
+
readonly enabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** The reference plugin: a scoped Parameter Store viewer and editor.
|
|
16
|
+
*
|
|
17
|
+
* Everything a customer writes for their own plugin is here — a manifest, a handler factory, and the
|
|
18
|
+
* IAM the handler needs. `addAccessPortal({ plugins: [parametersPlugin(...)] })` is the only wiring. */
|
|
19
|
+
export declare function parametersPlugin(props: ParametersPluginProps): PluginDefinition;
|
|
20
|
+
export { assertInScope, configuredPrefixes, ParameterScopeError } from "./scope";
|
|
21
|
+
export type { ParameterDetail, ParameterSummary, ParameterStore } from "./scope";
|