@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/dist/index.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ParameterScopeError = exports.configuredPrefixes = exports.assertInScope = void 0;
|
|
37
|
+
exports.parametersPlugin = parametersPlugin;
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const aws_cdk_lib_1 = require("aws-cdk-lib");
|
|
40
|
+
const iam = __importStar(require("aws-cdk-lib/aws-iam"));
|
|
41
|
+
const lambda = __importStar(require("aws-cdk-lib/aws-lambda"));
|
|
42
|
+
const infra_client_plugin_cdk_1 = require("@fntio/infra-client-plugin-cdk");
|
|
43
|
+
/** The reference plugin: a scoped Parameter Store viewer and editor.
|
|
44
|
+
*
|
|
45
|
+
* Everything a customer writes for their own plugin is here — a manifest, a handler factory, and the
|
|
46
|
+
* IAM the handler needs. `addAccessPortal({ plugins: [parametersPlugin(...)] })` is the only wiring. */
|
|
47
|
+
function parametersPlugin(props) {
|
|
48
|
+
const prefixes = props.prefixes.map((p) => (p.endsWith("/") ? p : `${p}/`));
|
|
49
|
+
if (!prefixes.length)
|
|
50
|
+
throw new Error("parametersPlugin needs at least one parameter prefix");
|
|
51
|
+
return (0, infra_client_plugin_cdk_1.definePlugin)({
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
id: "parameters",
|
|
54
|
+
displayName: "Application parameters",
|
|
55
|
+
description: "Read and edit SSM parameters under the configured prefixes.",
|
|
56
|
+
version: props.version ?? "1.0.0",
|
|
57
|
+
hostApiVersion: 1,
|
|
58
|
+
enabled: props.enabled ?? true,
|
|
59
|
+
accounts: [{ accountNumber: props.accountNumber, ...(props.region ? { regions: [props.region] } : {}) }],
|
|
60
|
+
permissions: [
|
|
61
|
+
{ id: "user", label: "View application parameters" },
|
|
62
|
+
{ id: "admin", label: "Edit application parameters", description: "Includes viewing." },
|
|
63
|
+
],
|
|
64
|
+
ui: {
|
|
65
|
+
assets: path.join(__dirname, "..", "web-dist"),
|
|
66
|
+
user: { label: "Parameters", icon: "sliders", order: 10, entry: "index.html#/user" },
|
|
67
|
+
admin: { label: "Parameters", icon: "sliders", order: 10, entry: "index.html#/admin" },
|
|
68
|
+
},
|
|
69
|
+
backend: {
|
|
70
|
+
handlers: { api: (scope) => apiFunction(scope, props.accountNumber, prefixes) },
|
|
71
|
+
routes: [
|
|
72
|
+
{ id: "list", method: "GET", path: "/parameters", permission: "user", handlerKey: "api" },
|
|
73
|
+
{ id: "get", method: "GET", path: "/parameters/{name}", permission: "user", handlerKey: "api" },
|
|
74
|
+
{ id: "update", method: "PUT", path: "/parameters/{name}", permission: "admin", handlerKey: "api" },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function apiFunction(scope, accountNumber, prefixes) {
|
|
80
|
+
// The bundle is built when this package is built (scripts/build-lambda.mjs), so a deployment
|
|
81
|
+
// that installs the package needs no bundler of its own.
|
|
82
|
+
const fn = new lambda.Function(scope, "ParametersApi", {
|
|
83
|
+
code: lambda.Code.fromAsset(path.join(__dirname, "..", "lambda-dist")),
|
|
84
|
+
description: "Example plugin API: reads the allowed SSM parameters in this account.",
|
|
85
|
+
handler: "index.handler",
|
|
86
|
+
runtime: lambda.Runtime.NODEJS_22_X,
|
|
87
|
+
timeout: aws_cdk_lib_1.Duration.seconds(10),
|
|
88
|
+
environment: {
|
|
89
|
+
PLUGIN_ACCOUNT_NUMBER: accountNumber,
|
|
90
|
+
PARAMETER_PREFIXES: prefixes.join(","),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
// The policy mirrors the handler's own rule, so neither alone is the only thing standing between a
|
|
94
|
+
// caller and the rest of the account's parameters.
|
|
95
|
+
const arns = prefixes.map((prefix) => `arn:aws:ssm:*:${accountNumber}:parameter${prefix}*`);
|
|
96
|
+
fn.addToRolePolicy(new iam.PolicyStatement({
|
|
97
|
+
actions: ["ssm:GetParameter", "ssm:GetParameters", "ssm:PutParameter"],
|
|
98
|
+
resources: arns,
|
|
99
|
+
}));
|
|
100
|
+
// GetParametersByPath authorizes on the path itself, not the parameters under it.
|
|
101
|
+
fn.addToRolePolicy(new iam.PolicyStatement({
|
|
102
|
+
actions: ["ssm:GetParametersByPath"],
|
|
103
|
+
resources: prefixes.map((prefix) => `arn:aws:ssm:*:${accountNumber}:parameter${prefix.replace(/\/$/, "")}`),
|
|
104
|
+
}));
|
|
105
|
+
return fn;
|
|
106
|
+
}
|
|
107
|
+
// From scope.ts, never from parameters.ts: a re-export here decides what a consumer has to have
|
|
108
|
+
// installed to synthesize, and parameters.ts imports the AWS SDK client the Lambda uses.
|
|
109
|
+
var scope_1 = require("./scope");
|
|
110
|
+
Object.defineProperty(exports, "assertInScope", { enumerable: true, get: function () { return scope_1.assertInScope; } });
|
|
111
|
+
Object.defineProperty(exports, "configuredPrefixes", { enumerable: true, get: function () { return scope_1.configuredPrefixes; } });
|
|
112
|
+
Object.defineProperty(exports, "ParameterScopeError", { enumerable: true, get: function () { return scope_1.ParameterScopeError; } });
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ssmParameterStore = ssmParameterStore;
|
|
4
|
+
const client_ssm_1 = require("@aws-sdk/client-ssm");
|
|
5
|
+
// The SSM-backed half of the plugin's rules, loaded only inside the Lambda. Everything the
|
|
6
|
+
// registration needs lives in scope.ts, which imports nothing from the AWS SDK.
|
|
7
|
+
const isSecret = (type) => type === "SecureString";
|
|
8
|
+
function ssmParameterStore(client) {
|
|
9
|
+
return {
|
|
10
|
+
async list(prefixes) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const prefix of prefixes) {
|
|
13
|
+
let NextToken;
|
|
14
|
+
do {
|
|
15
|
+
const page = await client.send(new client_ssm_1.GetParametersByPathCommand({ Path: prefix.replace(/\/$/, ""), Recursive: true, WithDecryption: false, NextToken }));
|
|
16
|
+
for (const p of page.Parameters ?? []) {
|
|
17
|
+
if (!p.Name)
|
|
18
|
+
continue;
|
|
19
|
+
out.push({
|
|
20
|
+
name: p.Name,
|
|
21
|
+
type: p.Type ?? "String",
|
|
22
|
+
...(p.LastModifiedDate ? { lastModified: p.LastModifiedDate.toISOString() } : {}),
|
|
23
|
+
...(p.Version === undefined ? {} : { version: p.Version }),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
NextToken = page.NextToken;
|
|
27
|
+
} while (NextToken);
|
|
28
|
+
}
|
|
29
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
30
|
+
},
|
|
31
|
+
async get(name) {
|
|
32
|
+
// WithDecryption stays false on purpose: a SecureString comes back as metadata only.
|
|
33
|
+
const res = await client.send(new client_ssm_1.GetParameterCommand({ Name: name, WithDecryption: false }));
|
|
34
|
+
const p = res.Parameter ?? {};
|
|
35
|
+
const secret = isSecret(p.Type);
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
type: p.Type ?? "String",
|
|
39
|
+
secret,
|
|
40
|
+
...(secret ? {} : { value: p.Value ?? "" }),
|
|
41
|
+
...(p.LastModifiedDate ? { lastModified: p.LastModifiedDate.toISOString() } : {}),
|
|
42
|
+
...(p.Version === undefined ? {} : { version: p.Version }),
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
async put(name, value) {
|
|
46
|
+
const res = await client.send(new client_ssm_1.PutParameterCommand({ Name: name, Value: value, Overwrite: true }));
|
|
47
|
+
return res.Version ?? 0;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/scope.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ParameterSummary {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
/** SecureString values are never fetched decrypted, so the type is how the UI knows to say so. */
|
|
4
|
+
readonly type: string;
|
|
5
|
+
readonly lastModified?: string;
|
|
6
|
+
readonly version?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ParameterDetail extends ParameterSummary {
|
|
9
|
+
/** Absent for a SecureString: this sample never displays a decrypted secret. */
|
|
10
|
+
readonly value?: string;
|
|
11
|
+
readonly secret: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare class ParameterScopeError extends Error {
|
|
14
|
+
}
|
|
15
|
+
/** Prefixes this deployment allows, from the registration. Empty = the plugin can touch nothing. */
|
|
16
|
+
export declare function configuredPrefixes(raw?: string | undefined): string[];
|
|
17
|
+
/** A name is in scope only if it sits under a configured prefix and cannot climb out of it. A `..`
|
|
18
|
+
* segment, a missing leading slash, or a name that merely starts with the prefix as a string
|
|
19
|
+
* (`/app-secrets` against `/app`) are all refused. */
|
|
20
|
+
export declare function assertInScope(name: string, prefixes: string[]): void;
|
|
21
|
+
export interface ParameterStore {
|
|
22
|
+
list(prefixes: string[]): Promise<ParameterSummary[]>;
|
|
23
|
+
get(name: string): Promise<ParameterDetail>;
|
|
24
|
+
put(name: string, value: string): Promise<number>;
|
|
25
|
+
}
|
package/dist/scope.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// What this plugin is willing to touch, and nothing that talks to AWS. The registration
|
|
3
|
+
// (index.ts) re-exports from here, so loading it on a developer's machine never pulls the
|
|
4
|
+
// Lambda's runtime dependencies in with it — a consumer installs the package without them.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ParameterScopeError = void 0;
|
|
7
|
+
exports.configuredPrefixes = configuredPrefixes;
|
|
8
|
+
exports.assertInScope = assertInScope;
|
|
9
|
+
class ParameterScopeError extends Error {
|
|
10
|
+
}
|
|
11
|
+
exports.ParameterScopeError = ParameterScopeError;
|
|
12
|
+
/** Prefixes this deployment allows, from the registration. Empty = the plugin can touch nothing. */
|
|
13
|
+
function configuredPrefixes(raw = process.env.PARAMETER_PREFIXES) {
|
|
14
|
+
return (raw ?? "")
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((p) => p.trim())
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.map((p) => (p.endsWith("/") ? p : `${p}/`));
|
|
19
|
+
}
|
|
20
|
+
/** A name is in scope only if it sits under a configured prefix and cannot climb out of it. A `..`
|
|
21
|
+
* segment, a missing leading slash, or a name that merely starts with the prefix as a string
|
|
22
|
+
* (`/app-secrets` against `/app`) are all refused. */
|
|
23
|
+
function assertInScope(name, prefixes) {
|
|
24
|
+
if (!name.startsWith("/"))
|
|
25
|
+
throw new ParameterScopeError("A parameter name starts with /");
|
|
26
|
+
if (name.split("/").includes(".."))
|
|
27
|
+
throw new ParameterScopeError("A parameter name cannot contain ..");
|
|
28
|
+
if (!/^[a-zA-Z0-9_.\-/]+$/.test(name))
|
|
29
|
+
throw new ParameterScopeError("That parameter name has characters SSM does not allow");
|
|
30
|
+
if (!prefixes.some((prefix) => name.startsWith(prefix))) {
|
|
31
|
+
throw new ParameterScopeError(`This plugin is limited to ${prefixes.join(", ") || "no prefixes"}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/handler.ts
|
|
21
|
+
var handler_exports = {};
|
|
22
|
+
__export(handler_exports, {
|
|
23
|
+
handler: () => handler,
|
|
24
|
+
route: () => route
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(handler_exports);
|
|
27
|
+
var import_client_ssm2 = require("@aws-sdk/client-ssm");
|
|
28
|
+
|
|
29
|
+
// ../infra-client-plugin-sdk/src/contracts/manifest.ts
|
|
30
|
+
var PLUGIN_PERMISSIONS = ["user", "admin"];
|
|
31
|
+
var PLUGIN_ACCOUNT_PARAM = "accountNumber";
|
|
32
|
+
var PLUGIN_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
33
|
+
var AWS_ACCOUNT_PATTERN = /^\d{12}$/;
|
|
34
|
+
|
|
35
|
+
// ../infra-client-plugin-sdk/src/contracts/paths.ts
|
|
36
|
+
var PLUGIN_API_ROOT = "/api/plugins";
|
|
37
|
+
function pluginApiBaseUrl(pluginId) {
|
|
38
|
+
return `${PLUGIN_API_ROOT}/${pluginId}`;
|
|
39
|
+
}
|
|
40
|
+
function pluginAccountRoot(pluginId) {
|
|
41
|
+
return `${pluginApiBaseUrl(pluginId)}/accounts/{${PLUGIN_ACCOUNT_PARAM}}`;
|
|
42
|
+
}
|
|
43
|
+
function pluginAccountPath(pluginId, accountNumber) {
|
|
44
|
+
return pluginAccountRoot(pluginId).replace(`{${PLUGIN_ACCOUNT_PARAM}}`, accountNumber);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ../infra-client-plugin-sdk/src/contracts/authorizer.ts
|
|
48
|
+
var PLUGIN_AUTHORIZER_CONTEXT_KEYS = [
|
|
49
|
+
"pluginId",
|
|
50
|
+
"pluginRevision",
|
|
51
|
+
"routeId",
|
|
52
|
+
"accountNumber",
|
|
53
|
+
"requiredPermission",
|
|
54
|
+
"subject",
|
|
55
|
+
"principalType",
|
|
56
|
+
"correlationId"
|
|
57
|
+
];
|
|
58
|
+
function parsePluginAuthorizerContext(raw) {
|
|
59
|
+
const ctx = tryParsePluginAuthorizerContext(raw);
|
|
60
|
+
if (!ctx) throw new Error("plugin authorizer context is missing or malformed");
|
|
61
|
+
return ctx;
|
|
62
|
+
}
|
|
63
|
+
function tryParsePluginAuthorizerContext(raw) {
|
|
64
|
+
if (!raw || typeof raw !== "object") return null;
|
|
65
|
+
const r = raw;
|
|
66
|
+
for (const key of PLUGIN_AUTHORIZER_CONTEXT_KEYS) {
|
|
67
|
+
if (typeof r[key] !== "string" || r[key].length === 0) return null;
|
|
68
|
+
}
|
|
69
|
+
if (!PLUGIN_ID_PATTERN.test(r.pluginId)) return null;
|
|
70
|
+
if (!AWS_ACCOUNT_PATTERN.test(r.accountNumber)) return null;
|
|
71
|
+
if (!PLUGIN_PERMISSIONS.includes(r.requiredPermission)) return null;
|
|
72
|
+
if (r.principalType !== "user" && r.principalType !== "service") return null;
|
|
73
|
+
return {
|
|
74
|
+
pluginId: r.pluginId,
|
|
75
|
+
pluginRevision: r.pluginRevision,
|
|
76
|
+
routeId: r.routeId,
|
|
77
|
+
accountNumber: r.accountNumber,
|
|
78
|
+
requiredPermission: r.requiredPermission,
|
|
79
|
+
subject: r.subject,
|
|
80
|
+
principalType: r.principalType,
|
|
81
|
+
correlationId: r.correlationId
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ../infra-client-plugin-sdk/src/backend/context.ts
|
|
86
|
+
function pluginAuthorizerContextOf(event) {
|
|
87
|
+
return parsePluginAuthorizerContext(event.requestContext?.authorizer);
|
|
88
|
+
}
|
|
89
|
+
function pluginRoutePath(path, ctx) {
|
|
90
|
+
const root = pluginAccountPath(ctx.pluginId, ctx.accountNumber);
|
|
91
|
+
if (path === root) return "/";
|
|
92
|
+
if (!path.startsWith(`${root}/`)) return null;
|
|
93
|
+
return path.slice(root.length);
|
|
94
|
+
}
|
|
95
|
+
var SENSITIVE = /token|authorization|secret|password|credential/i;
|
|
96
|
+
function createPluginLogger(ctx, sink = (line) => console.log(line)) {
|
|
97
|
+
const base = {
|
|
98
|
+
pluginId: ctx.pluginId,
|
|
99
|
+
accountNumber: ctx.accountNumber,
|
|
100
|
+
routeId: ctx.routeId,
|
|
101
|
+
subject: ctx.subject,
|
|
102
|
+
principalType: ctx.principalType,
|
|
103
|
+
correlationId: ctx.correlationId
|
|
104
|
+
};
|
|
105
|
+
const write = (level, msg, extra = {}) => {
|
|
106
|
+
const safe = {};
|
|
107
|
+
for (const [key, value] of Object.entries(extra)) safe[key] = SENSITIVE.test(key) ? "[redacted]" : value;
|
|
108
|
+
sink(JSON.stringify({ level, msg, ...safe, ...base }));
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
info: (msg, extra) => write("info", msg, extra),
|
|
112
|
+
warn: (msg, extra) => write("warn", msg, extra),
|
|
113
|
+
error: (msg, extra) => write("error", msg, extra)
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/parameters.ts
|
|
118
|
+
var import_client_ssm = require("@aws-sdk/client-ssm");
|
|
119
|
+
var isSecret = (type) => type === "SecureString";
|
|
120
|
+
function ssmParameterStore(client) {
|
|
121
|
+
return {
|
|
122
|
+
async list(prefixes) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const prefix of prefixes) {
|
|
125
|
+
let NextToken;
|
|
126
|
+
do {
|
|
127
|
+
const page = await client.send(
|
|
128
|
+
new import_client_ssm.GetParametersByPathCommand({ Path: prefix.replace(/\/$/, ""), Recursive: true, WithDecryption: false, NextToken })
|
|
129
|
+
);
|
|
130
|
+
for (const p of page.Parameters ?? []) {
|
|
131
|
+
if (!p.Name) continue;
|
|
132
|
+
out.push({
|
|
133
|
+
name: p.Name,
|
|
134
|
+
type: p.Type ?? "String",
|
|
135
|
+
...p.LastModifiedDate ? { lastModified: p.LastModifiedDate.toISOString() } : {},
|
|
136
|
+
...p.Version === void 0 ? {} : { version: p.Version }
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
NextToken = page.NextToken;
|
|
140
|
+
} while (NextToken);
|
|
141
|
+
}
|
|
142
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
143
|
+
},
|
|
144
|
+
async get(name) {
|
|
145
|
+
const res = await client.send(new import_client_ssm.GetParameterCommand({ Name: name, WithDecryption: false }));
|
|
146
|
+
const p = res.Parameter ?? {};
|
|
147
|
+
const secret = isSecret(p.Type);
|
|
148
|
+
return {
|
|
149
|
+
name,
|
|
150
|
+
type: p.Type ?? "String",
|
|
151
|
+
secret,
|
|
152
|
+
...secret ? {} : { value: p.Value ?? "" },
|
|
153
|
+
...p.LastModifiedDate ? { lastModified: p.LastModifiedDate.toISOString() } : {},
|
|
154
|
+
...p.Version === void 0 ? {} : { version: p.Version }
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
async put(name, value) {
|
|
158
|
+
const res = await client.send(new import_client_ssm.PutParameterCommand({ Name: name, Value: value, Overwrite: true }));
|
|
159
|
+
return res.Version ?? 0;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/scope.ts
|
|
165
|
+
var ParameterScopeError = class extends Error {
|
|
166
|
+
};
|
|
167
|
+
function configuredPrefixes(raw = process.env.PARAMETER_PREFIXES) {
|
|
168
|
+
return (raw ?? "").split(",").map((p) => p.trim()).filter(Boolean).map((p) => p.endsWith("/") ? p : `${p}/`);
|
|
169
|
+
}
|
|
170
|
+
function assertInScope(name, prefixes) {
|
|
171
|
+
if (!name.startsWith("/")) throw new ParameterScopeError("A parameter name starts with /");
|
|
172
|
+
if (name.split("/").includes("..")) throw new ParameterScopeError("A parameter name cannot contain ..");
|
|
173
|
+
if (!/^[a-zA-Z0-9_.\-/]+$/.test(name)) throw new ParameterScopeError("That parameter name has characters SSM does not allow");
|
|
174
|
+
if (!prefixes.some((prefix) => name.startsWith(prefix))) {
|
|
175
|
+
throw new ParameterScopeError(`This plugin is limited to ${prefixes.join(", ") || "no prefixes"}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/handler.ts
|
|
180
|
+
var ssm = new import_client_ssm2.SSMClient({});
|
|
181
|
+
var HOME_ACCOUNT = process.env.PLUGIN_ACCOUNT_NUMBER ?? "";
|
|
182
|
+
var json = (statusCode, body) => ({
|
|
183
|
+
statusCode,
|
|
184
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
185
|
+
body: JSON.stringify(body)
|
|
186
|
+
});
|
|
187
|
+
function deps() {
|
|
188
|
+
return { store: ssmParameterStore(ssm), prefixes: configuredPrefixes(), homeAccount: HOME_ACCOUNT };
|
|
189
|
+
}
|
|
190
|
+
async function handler(event) {
|
|
191
|
+
return route(event, deps());
|
|
192
|
+
}
|
|
193
|
+
async function route(event, d) {
|
|
194
|
+
let ctx;
|
|
195
|
+
try {
|
|
196
|
+
ctx = pluginAuthorizerContextOf(event);
|
|
197
|
+
} catch {
|
|
198
|
+
return json(500, { message: "This request did not come through the portal." });
|
|
199
|
+
}
|
|
200
|
+
const log = createPluginLogger(ctx);
|
|
201
|
+
if (!d.prefixes.length) {
|
|
202
|
+
log.error("no parameter prefixes configured");
|
|
203
|
+
return json(500, { message: "This plugin has no parameter prefixes configured." });
|
|
204
|
+
}
|
|
205
|
+
if (ctx.accountNumber !== d.homeAccount) {
|
|
206
|
+
log.error("authorized for an account this plugin does not serve", { requested: ctx.accountNumber });
|
|
207
|
+
return json(409, { message: `This plugin instance only serves account ${d.homeAccount}.` });
|
|
208
|
+
}
|
|
209
|
+
const path = pluginRoutePath(event.path, ctx);
|
|
210
|
+
if (path === null) return json(400, { message: "That path is outside this plugin." });
|
|
211
|
+
const method = event.httpMethod.toUpperCase();
|
|
212
|
+
const name = event.pathParameters?.["name"];
|
|
213
|
+
try {
|
|
214
|
+
if (method === "GET" && path === "/parameters") {
|
|
215
|
+
const parameters = await d.store.list(d.prefixes);
|
|
216
|
+
log.info("listed parameters", { count: parameters.length });
|
|
217
|
+
return json(200, { parameters, prefixes: d.prefixes });
|
|
218
|
+
}
|
|
219
|
+
if (method === "GET" && name) {
|
|
220
|
+
const decoded = decodeURIComponent(name);
|
|
221
|
+
assertInScope(decoded, d.prefixes);
|
|
222
|
+
const parameter = await d.store.get(decoded);
|
|
223
|
+
log.info("read parameter", { name: decoded, secret: parameter.secret });
|
|
224
|
+
return json(200, { parameter });
|
|
225
|
+
}
|
|
226
|
+
if (method === "PUT" && name) {
|
|
227
|
+
const decoded = decodeURIComponent(name);
|
|
228
|
+
assertInScope(decoded, d.prefixes);
|
|
229
|
+
const value = JSON.parse(event.body ?? "{}").value;
|
|
230
|
+
if (typeof value !== "string" || !value.length) {
|
|
231
|
+
return json(400, { message: "Send a non-empty string value." });
|
|
232
|
+
}
|
|
233
|
+
const version = await d.store.put(decoded, value);
|
|
234
|
+
log.info("updated parameter", { name: decoded, version });
|
|
235
|
+
return json(200, { name: decoded, version });
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (err instanceof ParameterScopeError) {
|
|
239
|
+
log.warn("refused an out-of-scope parameter", { error: err.message });
|
|
240
|
+
return json(400, { message: err.message });
|
|
241
|
+
}
|
|
242
|
+
if (err.name === "ParameterNotFound") {
|
|
243
|
+
return json(404, { message: "No such parameter." });
|
|
244
|
+
}
|
|
245
|
+
log.error("parameter operation failed", { error: err.message });
|
|
246
|
+
return json(502, { message: "The parameter store did not answer." });
|
|
247
|
+
}
|
|
248
|
+
return json(404, { message: `No such route: ${method} ${path}` });
|
|
249
|
+
}
|
|
250
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
251
|
+
0 && (module.exports = {
|
|
252
|
+
handler,
|
|
253
|
+
route
|
|
254
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,84 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fntio/infra-client-plugin-example",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The reference infra-client plugin: a scoped SSM Parameter Store viewer and editor, and the worked example the plugin docs and agent skills refer to.",
|
|
5
|
+
"license": "Elastic-2.0",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/FNTIO/aws-infrastructure-client.git",
|
|
12
|
+
"directory": "packages/infra-client-plugin-example"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/FNTIO/aws-infrastructure-client/wiki",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/FNTIO/aws-infrastructure-client/issues"
|
|
17
|
+
},
|
|
18
|
+
"main": "dist/index.js",
|
|
19
|
+
"types": "dist/index.d.ts",
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"lambda-dist",
|
|
23
|
+
"web-dist",
|
|
24
|
+
"web",
|
|
25
|
+
"src",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "npm run build:web && npm run build:lambda && tsc -p tsconfig.build.json",
|
|
33
|
+
"build:web": "node scripts/build-web.mjs",
|
|
34
|
+
"build:lambda": "node scripts/build-lambda.mjs",
|
|
35
|
+
"prepack": "npm run build",
|
|
36
|
+
"pretest": "npm run build:lambda",
|
|
37
|
+
"test": "jest"
|
|
38
|
+
},
|
|
39
|
+
"jest": {
|
|
40
|
+
"testEnvironment": "node",
|
|
41
|
+
"roots": [
|
|
42
|
+
"<rootDir>/__tests__"
|
|
43
|
+
],
|
|
44
|
+
"testMatch": [
|
|
45
|
+
"**/*.test.ts"
|
|
46
|
+
],
|
|
47
|
+
"transform": {
|
|
48
|
+
"^.+\\.tsx?$": [
|
|
49
|
+
"ts-jest",
|
|
50
|
+
{
|
|
51
|
+
"tsconfig": "<rootDir>/tsconfig.jest.json"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"moduleNameMapper": {
|
|
56
|
+
"^@fntio/infra-client-plugin-sdk$": "<rootDir>/../infra-client-plugin-sdk/src/index.ts",
|
|
57
|
+
"^@fntio/infra-client-plugin-sdk/(.*)$": "<rootDir>/../infra-client-plugin-sdk/src/$1/index.ts"
|
|
58
|
+
},
|
|
59
|
+
"maxWorkers": "50%",
|
|
60
|
+
"workerIdleMemoryLimit": "2GB"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@fntio/infra-client-plugin-sdk": "^0.2.0",
|
|
64
|
+
"@fntio/infra-client-plugin-cdk": "^0.2.0",
|
|
65
|
+
"@fntio/infra-client-plugin-ui": "^0.2.0"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"aws-cdk-lib": "^2.263.0",
|
|
69
|
+
"constructs": "^10.3.0"
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@aws-sdk/client-ssm": "^3.600.0",
|
|
73
|
+
"@types/aws-lambda": "^8.10.145",
|
|
74
|
+
"@types/jest": "^29.5.14",
|
|
75
|
+
"@types/node": "^20.19.43",
|
|
76
|
+
"aws-cdk-lib": "2.263.0",
|
|
77
|
+
"aws-sdk-client-mock": "^4.0.1",
|
|
78
|
+
"constructs": "^10.3.0",
|
|
79
|
+
"esbuild": "^0.23.0",
|
|
80
|
+
"jest": "^29.7.0",
|
|
81
|
+
"ts-jest": "^29.2.0",
|
|
82
|
+
"typescript": "^5.5.0"
|
|
83
|
+
}
|
|
84
|
+
}
|