@microteia/console 2.29.1 → 2.31.1
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/CONSOLE-VERSIONS.md +26 -0
- package/README.md +1 -1
- package/lib/console/deploy.js +18 -0
- package/lib/console/exportCatalog.d.ts +22 -0
- package/lib/console/exportCatalog.js +42 -0
- package/lib/console/sessionSigning.d.ts +64 -0
- package/lib/console/sessionSigning.js +203 -0
- package/lib/index.js +8 -0
- package/lib/kernel/manifest.js +64 -0
- package/lib/shared/paths.d.ts +1 -1
- package/lib/shared/paths.js +1 -1
- package/lib/vendor/installationDocs.js +1 -0
- package/package.json +1 -1
package/CONSOLE-VERSIONS.md
CHANGED
|
@@ -22,6 +22,32 @@
|
|
|
22
22
|
- fix(command): what was broken, and what it did to whoever hit it.
|
|
23
23
|
-->
|
|
24
24
|
|
|
25
|
+
#### [2.31.1] — 2026-10-26
|
|
26
|
+
|
|
27
|
+
- docs(kernel/manifest): the optional prerequisite `portal-identity-signing` now names a third feature that signs with the functions' runtime account: a portal screen declaring `documentLinks` hands guests V4 signed URLs, and without `iam.serviceAccounts.signBlob` the guest cannot open its documents (the denial shows in the log of `getPortalDocumentUrl`). `microteia grant-signing` grants the same permission for all three.
|
|
28
|
+
|
|
29
|
+
#### [2.31.0] — 2026-10-26
|
|
30
|
+
|
|
31
|
+
- feat(grant-signing): new command. Portal sign-in through an identity provider and member two-step verification open sessions with `createCustomToken`, which needs the functions' runtime account to sign (`iam.serviceAccounts.signBlob`). `microteia grant-signing` reads the account the functions actually run as (`gcloud functions describe secondFactor`; the default compute account only when that function is not deployed), checks whether `iamcredentials.googleapis.com` is enabled and whether the account holds `roles/iam.serviceAccountTokenCreator` on itself without a condition, prints the missing `gcloud` steps, asks before running them, and reads the state again afterwards. `--check` only reads; `--yes` skips the question; without a terminal and without `--yes` it refuses. A permission error names the roles needed and hands over the commands.
|
|
32
|
+
- feat(deploy): after the functions, a read-only check of session signing; when it is missing, a warning that points to `microteia grant-signing`. It never fails the deploy, and is skipped with a note when `gcloud` cannot answer.
|
|
33
|
+
- docs(installation): the command table of `MICROTEIA.md` lists `grant-signing`.
|
|
34
|
+
|
|
35
|
+
#### [2.30.3] — 2026-10-26
|
|
36
|
+
|
|
37
|
+
- docs(kernel/manifest): the optional prerequisite `portal-identity-signing` now covers member two-step verification too — both open sessions minted with `createCustomToken`, and both fail at sign-in with `iam.serviceAccounts.signBlob` denied when the runtime account cannot sign. New optional prerequisite `self-service-tenants`: `SELF_SERVICE_TENANTS=false` in `.env.<projectId>` plus `microteia provision` stops sign-up from opening an organization in a shared installation; only SYSTEM opens organizations afterwards, and SYSTEM accounts should be protected by a sign-in provider with its own second factor. The kernel's declared optional backend parameters are now `AUDIT_ARCHIVE_BUCKET` and `SELF_SERVICE_TENANTS`.
|
|
38
|
+
|
|
39
|
+
#### [2.30.2] — 2026-10-26
|
|
40
|
+
|
|
41
|
+
- docs(kernel/manifest): new optional manual prerequisite `audit-archive-bucket`. An organization that keeps its audit trail as evidence turns on the daily archive with hash chain, which needs a bucket of its own with locked retention (`--enable-per-object-retention --retention-period=…`, then `--lock-retention-period`), `roles/storage.admin` on that bucket for the functions' runtime account, and `AUDIT_ARCHIVE_BUCKET` in `.env.<projectId>` followed by `microteia provision`. The step states the threat model: what a locked bucket prevents, what the chain only detects, and what a project owner can still do.
|
|
42
|
+
|
|
43
|
+
#### [2.30.1] — 2026-10-26
|
|
44
|
+
|
|
45
|
+
- docs(kernel/manifest): new optional manual prerequisite `portal-identity-signing`. An organization that signs portal guests in through an OpenID Connect provider needs the functions' runtime service account to sign sessions (`createCustomToken` → `iam.serviceAccounts.signBlob`): the IAM Service Account Credentials API enabled and the Service Account Token Creator role granted to that account on itself. Without it the provider confirms the guest and `completePortalSignIn` still fails; the step names the two `gcloud` commands. E-mail-link sign-in does not need it.
|
|
46
|
+
|
|
47
|
+
#### [2.30.0] — 2026-10-23
|
|
48
|
+
|
|
49
|
+
- feat(export-catalog): each installed extension now carries `documentTemplates` — every `document.template` it declares as a contract (`key`, `source`, and each parameter's `key`, `type`, `required`, `list`, `entityType`), never the template text, labels or sample. The authoring tools check a `render-document` step of another extension against it, so a misspelled `param.<key>` or a `belongsTo` that is not a single-record parameter is refused by `check` instead of reaching the office as a blank field. Always written, even empty; a snapshot taken by an older Console is read as "templates unknown".
|
|
50
|
+
|
|
25
51
|
#### [2.29.1] — 2026-10-19
|
|
26
52
|
|
|
27
53
|
- feat(vendor): `portal-example` joins the examples that travel to `extensions/examples/` — two screens with `portal` declared, guest-created records and a `portalView` written against `@microteia/portal`.
|
package/README.md
CHANGED
package/lib/console/deploy.js
CHANGED
|
@@ -10,6 +10,7 @@ import { clearRegionOrphans, listDeployedFunctions } from './regionOrphans.js';
|
|
|
10
10
|
import { gateProblemsFor } from './functionGate.js';
|
|
11
11
|
import { deployFunctionsThatChanged } from './functionsUpToDate.js';
|
|
12
12
|
import { checkCallables, invokerGrantCommand } from './callableAccess.js';
|
|
13
|
+
import { checkSessionSigning, gcloudCapture } from './sessionSigning.js';
|
|
13
14
|
import { checkHostingServed, htmlIsRevalidated } from './hostingCheck.js';
|
|
14
15
|
import { provisionBackend } from './provision.js';
|
|
15
16
|
import { installPath, readInstalledManifest } from './unpack.js';
|
|
@@ -233,6 +234,23 @@ export const deploy = async (args) => {
|
|
|
233
234
|
success(`all ${checks.length} callables answer calls from ${origin}`);
|
|
234
235
|
}
|
|
235
236
|
}
|
|
237
|
+
// Só CONFERE: conceder é mudar permissão no projeto do escritório, e isso é um comando à parte, que
|
|
238
|
+
// diz o que vai mudar e pergunta. A falta não reprova o deploy — só quem usa entrada por provedor
|
|
239
|
+
// no portal ou verificação em duas etapas precisa dela —, mas é dita aqui, e não na primeira pessoa
|
|
240
|
+
// que tentar entrar.
|
|
241
|
+
try {
|
|
242
|
+
const signing = await checkSessionSigning(projectId, region, gcloudCapture);
|
|
243
|
+
if (signing.apiEnabled === false || signing.granted === false) {
|
|
244
|
+
warn(`the functions' account (${signing.account}) cannot sign sessions yet. Portal sign-in through`);
|
|
245
|
+
warn(' an identity provider and two-step verification need it. Run: microteia grant-signing');
|
|
246
|
+
}
|
|
247
|
+
else if (signing.apiEnabled === true && signing.granted === true) {
|
|
248
|
+
success('the functions can sign sessions');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
info(' could not check whether the functions can sign sessions — run "microteia grant-signing --check"');
|
|
253
|
+
}
|
|
236
254
|
continue;
|
|
237
255
|
}
|
|
238
256
|
step(current, total, 'Hosting');
|
|
@@ -112,6 +112,26 @@ export interface CatalogExtensionNavGroup {
|
|
|
112
112
|
translations: Record<string, string>;
|
|
113
113
|
order: number;
|
|
114
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Um modelo de documento que a extensão declara (`document.template`), como CONTRATO: a chave, a origem
|
|
117
|
+
* e a forma de cada parâmetro. Viaja porque outra extensão GERA esse modelo pelo passo
|
|
118
|
+
* `render-document`, com `param.<chave>` e `belongsTo`, e um nome trocado ali só aparece na geração —
|
|
119
|
+
* com o campo em branco, ou com a recusa no clique.
|
|
120
|
+
*
|
|
121
|
+
* O que NÃO viaja é o texto do modelo, o rótulo, a regra e a amostra: é conteúdo, e o passo não os lê.
|
|
122
|
+
*/
|
|
123
|
+
export interface CatalogExtensionDocumentParameter {
|
|
124
|
+
key: string;
|
|
125
|
+
type: string;
|
|
126
|
+
required?: boolean;
|
|
127
|
+
list?: boolean;
|
|
128
|
+
entityType?: string;
|
|
129
|
+
}
|
|
130
|
+
export interface CatalogExtensionDocumentTemplate {
|
|
131
|
+
key: string;
|
|
132
|
+
source: 'record' | 'payload';
|
|
133
|
+
parameters: CatalogExtensionDocumentParameter[];
|
|
134
|
+
}
|
|
115
135
|
export interface CatalogExtensionExport {
|
|
116
136
|
id: string;
|
|
117
137
|
name: string;
|
|
@@ -124,6 +144,8 @@ export interface CatalogExtensionExport {
|
|
|
124
144
|
exports: CatalogExtensionExportedArtifact[];
|
|
125
145
|
/** Sempre escrita, mesmo vazia, pela mesma razão de `exports`. */
|
|
126
146
|
navGroups: CatalogExtensionNavGroup[];
|
|
147
|
+
/** Sempre escrita, mesmo vazia, pela mesma razão de `exports`. */
|
|
148
|
+
documentTemplates: CatalogExtensionDocumentTemplate[];
|
|
127
149
|
}
|
|
128
150
|
export interface CatalogExport {
|
|
129
151
|
version: number;
|
|
@@ -126,6 +126,45 @@ const navGroupsOf = (extension) => {
|
|
|
126
126
|
}];
|
|
127
127
|
});
|
|
128
128
|
};
|
|
129
|
+
const DOCUMENT_TEMPLATE_POINT = 'document.template';
|
|
130
|
+
const documentParameterOf = (raw) => {
|
|
131
|
+
const record = asRecord(raw);
|
|
132
|
+
const key = text(record?.key);
|
|
133
|
+
const type = text(record?.type);
|
|
134
|
+
if (record === undefined || key === undefined || type === undefined)
|
|
135
|
+
return [];
|
|
136
|
+
const entityType = text(record.entityType);
|
|
137
|
+
return [{
|
|
138
|
+
key,
|
|
139
|
+
type,
|
|
140
|
+
...(record.required === true ? { required: true } : {}),
|
|
141
|
+
...(record.list === true ? { list: true } : {}),
|
|
142
|
+
...(entityType === undefined ? {} : { entityType }),
|
|
143
|
+
}];
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Os modelos de documento de uma cópia instalada, como contrato. É a mesma leitura que
|
|
147
|
+
* `documentTemplateContractOf` faz no kernel, repetida porque o Console não alcança `src/` — e é o
|
|
148
|
+
* teste que atravessa a fronteira que confere as duas.
|
|
149
|
+
*/
|
|
150
|
+
const documentTemplatesOf = (extension) => {
|
|
151
|
+
const contributes = Array.isArray(extension.manifest?.contributes) ? extension.manifest.contributes : [];
|
|
152
|
+
return contributes
|
|
153
|
+
.map(asRecord)
|
|
154
|
+
.filter((one) => one?.point === DOCUMENT_TEMPLATE_POINT)
|
|
155
|
+
.flatMap((one) => {
|
|
156
|
+
const metadata = asRecord(one.metadata);
|
|
157
|
+
const key = text(metadata?.key);
|
|
158
|
+
if (metadata === undefined || key === undefined)
|
|
159
|
+
return [];
|
|
160
|
+
return [{
|
|
161
|
+
key,
|
|
162
|
+
source: metadata.source === 'payload' ? 'payload' : 'record',
|
|
163
|
+
parameters: (Array.isArray(metadata.parameters) ? metadata.parameters : []).flatMap(documentParameterOf),
|
|
164
|
+
}];
|
|
165
|
+
})
|
|
166
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
167
|
+
};
|
|
129
168
|
/**
|
|
130
169
|
* A mesma dedução que a aplicação faz ao ler a tela (`screenDefinitionMapper.ts`): o `origin` gravado
|
|
131
170
|
* quando ele existe, senão `plugin` para quem tem dono e `design` para o que o escritório criou. Ela é
|
|
@@ -223,6 +262,7 @@ export const buildCatalogExport = (input) => ({
|
|
|
223
262
|
active: one.active,
|
|
224
263
|
exports: exportedArtifacts(one),
|
|
225
264
|
navGroups: navGroupsOf(one),
|
|
265
|
+
documentTemplates: documentTemplatesOf(one),
|
|
226
266
|
}))
|
|
227
267
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
|
|
228
268
|
screens: input.screens
|
|
@@ -344,6 +384,8 @@ export const exportCatalog = async (args) => {
|
|
|
344
384
|
info(` ${exported} artifact(s) are declared for other extensions to reference as <extension-id>/<id>.`);
|
|
345
385
|
const groups = document.extensions.reduce((total, one) => total + one.navGroups.length, 0);
|
|
346
386
|
info(` ${groups} sidebar group(s) are declared, by key, for other extensions to join.`);
|
|
387
|
+
const templates = document.extensions.reduce((total, one) => total + one.documentTemplates.length, 0);
|
|
388
|
+
info(` ${templates} document template(s) are declared, with the shape of their parameters, for other extensions to generate.`);
|
|
347
389
|
blank();
|
|
348
390
|
await checkIgnored(directory);
|
|
349
391
|
info('It is a SNAPSHOT, and it says so: the authoring tools report when it was taken and warn when it');
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ParsedArgs } from '../shared/args.js';
|
|
2
|
+
import type { CaptureResult } from '../shared/run.js';
|
|
3
|
+
/**
|
|
4
|
+
* A permissão de ASSINAR sessões — a que `createCustomToken` usa. Duas features abrem sessão pelo servidor:
|
|
5
|
+
* a entrada do portal por provedor de identidade e a verificação em duas etapas dos membros. Sem a
|
|
6
|
+
* permissão, o provedor confirma a pessoa ou o código confere, e a entrada falha mesmo assim, com
|
|
7
|
+
* `iam.serviceAccounts.signBlob` negado no log da function.
|
|
8
|
+
*
|
|
9
|
+
* São duas coisas no projeto do escritório: a API IAM Service Account Credentials habilitada, e o papel
|
|
10
|
+
* Service Account Token Creator concedido à conta das functions SOBRE ELA MESMA.
|
|
11
|
+
*
|
|
12
|
+
* O Console não as faz sozinho no `deploy`: é alteração de permissão no projeto de outra pessoa, e
|
|
13
|
+
* acontece num comando que diz o que vai mudar e pergunta antes. O `deploy` só CONFERE, e sugere este.
|
|
14
|
+
*/
|
|
15
|
+
export declare const IAM_CREDENTIALS_API = "iamcredentials.googleapis.com";
|
|
16
|
+
export declare const TOKEN_CREATOR_ROLE = "roles/iam.serviceAccountTokenCreator";
|
|
17
|
+
/**
|
|
18
|
+
* A function cuja conta se lê. Uma do kernel que de fato assina — se ela roda com uma conta própria, é
|
|
19
|
+
* essa que precisa da permissão, e escrever de cor a conta padrão do Compute erraria em silêncio.
|
|
20
|
+
*/
|
|
21
|
+
export declare const SIGNING_FUNCTION = "secondFactor";
|
|
22
|
+
export type Gcloud = (args: string[]) => Promise<CaptureResult>;
|
|
23
|
+
export type AccountSource = 'function' | 'default';
|
|
24
|
+
export interface SigningCheck {
|
|
25
|
+
account: string;
|
|
26
|
+
source: AccountSource;
|
|
27
|
+
/** `null` quando não deu para saber — permissão de leitura faltando, `gcloud` ausente. */
|
|
28
|
+
apiEnabled: boolean | null;
|
|
29
|
+
granted: boolean | null;
|
|
30
|
+
}
|
|
31
|
+
/** A conta padrão das functions de 2ª geração quando nenhuma foi configurada. */
|
|
32
|
+
export declare const defaultComputeAccount: (projectNumber: string) => string;
|
|
33
|
+
/**
|
|
34
|
+
* A conta que a function usa, no JSON do `gcloud functions describe`. A 2ª geração a põe em
|
|
35
|
+
* `serviceConfig`; a 1ª, na raiz. Vazio é "não sei", e não "a padrão": a padrão é decidida por quem chama.
|
|
36
|
+
*/
|
|
37
|
+
export declare const serviceAccountFromDescribe: (json: string) => string | null;
|
|
38
|
+
/** A política IAM da conta concede a ela mesma criar token? */
|
|
39
|
+
export declare const policyGrantsSelfSigning: (json: string, account: string) => boolean;
|
|
40
|
+
export declare const enableApiArgs: (projectId: string) => string[];
|
|
41
|
+
export declare const grantArgs: (projectId: string, account: string) => string[];
|
|
42
|
+
export declare const gcloudLine: (args: string[]) => string;
|
|
43
|
+
/**
|
|
44
|
+
* A conta que as functions usam: lida da function publicada e, só se não houver uma, a padrão do
|
|
45
|
+
* Compute a partir do número do projeto.
|
|
46
|
+
*/
|
|
47
|
+
export declare const resolveRuntimeAccount: (projectId: string, region: string, gcloud: Gcloud) => Promise<{
|
|
48
|
+
account: string;
|
|
49
|
+
source: AccountSource;
|
|
50
|
+
}>;
|
|
51
|
+
/** Só LÊ. É o que o `deploy` usa para avisar, e o que este comando usa antes e depois de mudar. */
|
|
52
|
+
export declare const checkSessionSigning: (projectId: string, region: string, gcloud: Gcloud) => Promise<SigningCheck>;
|
|
53
|
+
/** O que falta, como comandos — os que este comando roda, e os que se entregam a quem tem o papel. */
|
|
54
|
+
export declare const missingSteps: (projectId: string, check: SigningCheck) => string[][];
|
|
55
|
+
/** Nunca quebra quem chama: sem `gcloud`, o `spawn` falha, e a resposta é "não deu para ler". */
|
|
56
|
+
export declare const gcloudCapture: Gcloud;
|
|
57
|
+
/**
|
|
58
|
+
* `microteia grant-signing` — confere a permissão de assinar sessões e, com confirmação, a concede.
|
|
59
|
+
*
|
|
60
|
+
* `--check` só confere. `--yes` dispensa a pergunta (para quem roda num script, e sabe o que roda).
|
|
61
|
+
* Sem terminal e sem `--yes`, recusa: concordar por omissão com uma mudança de permissão é o que este
|
|
62
|
+
* comando existe para não fazer.
|
|
63
|
+
*/
|
|
64
|
+
export declare const grantSigning: (args: ParsedArgs) => Promise<void>;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { hasFlag, requiredFlag } from '../shared/args.js';
|
|
4
|
+
import { capture, run } from '../shared/run.js';
|
|
5
|
+
import { blank, bullets, failure, heading, info, success, warn } from '../shared/ui.js';
|
|
6
|
+
import { ENVIRONMENT_FILE, readEnvironment } from './environment.js';
|
|
7
|
+
import { readInstalledManifest } from './unpack.js';
|
|
8
|
+
/**
|
|
9
|
+
* A permissão de ASSINAR sessões — a que `createCustomToken` usa. Duas features abrem sessão pelo servidor:
|
|
10
|
+
* a entrada do portal por provedor de identidade e a verificação em duas etapas dos membros. Sem a
|
|
11
|
+
* permissão, o provedor confirma a pessoa ou o código confere, e a entrada falha mesmo assim, com
|
|
12
|
+
* `iam.serviceAccounts.signBlob` negado no log da function.
|
|
13
|
+
*
|
|
14
|
+
* São duas coisas no projeto do escritório: a API IAM Service Account Credentials habilitada, e o papel
|
|
15
|
+
* Service Account Token Creator concedido à conta das functions SOBRE ELA MESMA.
|
|
16
|
+
*
|
|
17
|
+
* O Console não as faz sozinho no `deploy`: é alteração de permissão no projeto de outra pessoa, e
|
|
18
|
+
* acontece num comando que diz o que vai mudar e pergunta antes. O `deploy` só CONFERE, e sugere este.
|
|
19
|
+
*/
|
|
20
|
+
export const IAM_CREDENTIALS_API = 'iamcredentials.googleapis.com';
|
|
21
|
+
export const TOKEN_CREATOR_ROLE = 'roles/iam.serviceAccountTokenCreator';
|
|
22
|
+
/**
|
|
23
|
+
* A function cuja conta se lê. Uma do kernel que de fato assina — se ela roda com uma conta própria, é
|
|
24
|
+
* essa que precisa da permissão, e escrever de cor a conta padrão do Compute erraria em silêncio.
|
|
25
|
+
*/
|
|
26
|
+
export const SIGNING_FUNCTION = 'secondFactor';
|
|
27
|
+
const GCLOUD_BIN = 'gcloud';
|
|
28
|
+
/** A conta padrão das functions de 2ª geração quando nenhuma foi configurada. */
|
|
29
|
+
export const defaultComputeAccount = (projectNumber) => `${projectNumber}-compute@developer.gserviceaccount.com`;
|
|
30
|
+
/**
|
|
31
|
+
* A conta que a function usa, no JSON do `gcloud functions describe`. A 2ª geração a põe em
|
|
32
|
+
* `serviceConfig`; a 1ª, na raiz. Vazio é "não sei", e não "a padrão": a padrão é decidida por quem chama.
|
|
33
|
+
*/
|
|
34
|
+
export const serviceAccountFromDescribe = (json) => {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(json);
|
|
37
|
+
const account = parsed.serviceConfig?.serviceAccountEmail ?? parsed.serviceAccountEmail;
|
|
38
|
+
return typeof account === 'string' && account.includes('@') ? account : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/** A política IAM da conta concede a ela mesma criar token? */
|
|
45
|
+
export const policyGrantsSelfSigning = (json, account) => {
|
|
46
|
+
const parsed = JSON.parse(json);
|
|
47
|
+
const member = `serviceAccount:${account}`;
|
|
48
|
+
return (parsed.bindings ?? []).some((binding) => binding.role === TOKEN_CREATOR_ROLE
|
|
49
|
+
&& binding.condition === undefined
|
|
50
|
+
&& Array.isArray(binding.members)
|
|
51
|
+
&& binding.members.includes(member));
|
|
52
|
+
};
|
|
53
|
+
export const enableApiArgs = (projectId) => ['services', 'enable', IAM_CREDENTIALS_API, `--project=${projectId}`];
|
|
54
|
+
export const grantArgs = (projectId, account) => [
|
|
55
|
+
'iam', 'service-accounts', 'add-iam-policy-binding', account,
|
|
56
|
+
`--member=serviceAccount:${account}`,
|
|
57
|
+
`--role=${TOKEN_CREATOR_ROLE}`,
|
|
58
|
+
`--project=${projectId}`,
|
|
59
|
+
];
|
|
60
|
+
export const gcloudLine = (args) => [GCLOUD_BIN, ...args].join(' ');
|
|
61
|
+
/**
|
|
62
|
+
* A conta que as functions usam: lida da function publicada e, só se não houver uma, a padrão do
|
|
63
|
+
* Compute a partir do número do projeto.
|
|
64
|
+
*/
|
|
65
|
+
export const resolveRuntimeAccount = async (projectId, region, gcloud) => {
|
|
66
|
+
const described = await gcloud([
|
|
67
|
+
'functions', 'describe', SIGNING_FUNCTION, `--region=${region}`, `--project=${projectId}`, '--format=json',
|
|
68
|
+
]);
|
|
69
|
+
const fromFunction = described.code === 0 ? serviceAccountFromDescribe(described.stdout) : null;
|
|
70
|
+
if (fromFunction)
|
|
71
|
+
return { account: fromFunction, source: 'function' };
|
|
72
|
+
const project = await gcloud(['projects', 'describe', projectId, '--format=value(projectNumber)']);
|
|
73
|
+
const projectNumber = project.stdout.trim();
|
|
74
|
+
if (project.code !== 0 || !/^\d+$/.test(projectNumber)) {
|
|
75
|
+
throw new Error(`Could not read the project number of ${projectId}.${project.stderr.trim() === '' ? '' : `\n ${project.stderr.trim()}`}`);
|
|
76
|
+
}
|
|
77
|
+
return { account: defaultComputeAccount(projectNumber), source: 'default' };
|
|
78
|
+
};
|
|
79
|
+
/** Só LÊ. É o que o `deploy` usa para avisar, e o que este comando usa antes e depois de mudar. */
|
|
80
|
+
export const checkSessionSigning = async (projectId, region, gcloud) => {
|
|
81
|
+
const { account, source } = await resolveRuntimeAccount(projectId, region, gcloud);
|
|
82
|
+
const services = await gcloud([
|
|
83
|
+
'services', 'list', '--enabled', `--project=${projectId}`,
|
|
84
|
+
`--filter=config.name=${IAM_CREDENTIALS_API}`, '--format=value(config.name)',
|
|
85
|
+
]);
|
|
86
|
+
const apiEnabled = services.code === 0 ? services.stdout.trim() === IAM_CREDENTIALS_API : null;
|
|
87
|
+
const policy = await gcloud(['iam', 'service-accounts', 'get-iam-policy', account, `--project=${projectId}`, '--format=json']);
|
|
88
|
+
let granted = null;
|
|
89
|
+
if (policy.code === 0) {
|
|
90
|
+
try {
|
|
91
|
+
granted = policyGrantsSelfSigning(policy.stdout, account);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
granted = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { account, source, apiEnabled, granted };
|
|
98
|
+
};
|
|
99
|
+
/** O que falta, como comandos — os que este comando roda, e os que se entregam a quem tem o papel. */
|
|
100
|
+
export const missingSteps = (projectId, check) => [
|
|
101
|
+
...(check.apiEnabled === true ? [] : [enableApiArgs(projectId)]),
|
|
102
|
+
...(check.granted === true ? [] : [grantArgs(projectId, check.account)]),
|
|
103
|
+
];
|
|
104
|
+
/** Nunca quebra quem chama: sem `gcloud`, o `spawn` falha, e a resposta é "não deu para ler". */
|
|
105
|
+
export const gcloudCapture = (args) => capture(GCLOUD_BIN, args).catch((error) => ({
|
|
106
|
+
code: -1,
|
|
107
|
+
stdout: '',
|
|
108
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
109
|
+
}));
|
|
110
|
+
const isPermissionError = (error) => error instanceof Error && /PERMISSION_DENIED|403|does not have .*permission|setIamPolicy/i.test(error.message);
|
|
111
|
+
const confirm = async (question) => {
|
|
112
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
113
|
+
try {
|
|
114
|
+
return /^(y|yes)$/i.test((await rl.question(`${question} [y/N] `)).trim());
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
rl.close();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const regionOf = async (root, recorded) => {
|
|
121
|
+
try {
|
|
122
|
+
return (await readInstalledManifest(root)).requires.functionsRegion;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return recorded;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* `microteia grant-signing` — confere a permissão de assinar sessões e, com confirmação, a concede.
|
|
130
|
+
*
|
|
131
|
+
* `--check` só confere. `--yes` dispensa a pergunta (para quem roda num script, e sabe o que roda).
|
|
132
|
+
* Sem terminal e sem `--yes`, recusa: concordar por omissão com uma mudança de permissão é o que este
|
|
133
|
+
* comando existe para não fazer.
|
|
134
|
+
*/
|
|
135
|
+
export const grantSigning = async (args) => {
|
|
136
|
+
const file = resolve(hasFlag(args, 'file') ? requiredFlag(args, 'file') : join(process.cwd(), ENVIRONMENT_FILE));
|
|
137
|
+
const root = dirname(file);
|
|
138
|
+
const environment = await readEnvironment(file);
|
|
139
|
+
const projectId = environment.firebase.projectId;
|
|
140
|
+
if (projectId === null) {
|
|
141
|
+
throw new Error('This installation is not connected yet. Run "microteia connect --project <id>".');
|
|
142
|
+
}
|
|
143
|
+
const region = await regionOf(root, environment.firebase.region);
|
|
144
|
+
heading(`Session signing in ${projectId}`);
|
|
145
|
+
info('Signing in to the portal with an identity provider, and confirming the code of two-step');
|
|
146
|
+
info('verification, open a session the server signs. The functions\' account must be allowed to do it.');
|
|
147
|
+
blank();
|
|
148
|
+
const before = await checkSessionSigning(projectId, region, gcloudCapture);
|
|
149
|
+
info(` account: ${before.account}`);
|
|
150
|
+
info(before.source === 'function'
|
|
151
|
+
? ` (read from the deployed function ${SIGNING_FUNCTION})`
|
|
152
|
+
: ` (the default compute account — ${SIGNING_FUNCTION} is not deployed yet, or could not be read)`);
|
|
153
|
+
const state = (value) => (value === null ? 'could not be read' : value ? 'yes' : 'no');
|
|
154
|
+
info(` ${IAM_CREDENTIALS_API} enabled: ${state(before.apiEnabled)}`);
|
|
155
|
+
info(` ${TOKEN_CREATOR_ROLE} on itself: ${state(before.granted)}`);
|
|
156
|
+
blank();
|
|
157
|
+
const steps = missingSteps(projectId, before);
|
|
158
|
+
if (steps.length === 0) {
|
|
159
|
+
success('the functions can sign sessions — nothing to change');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
info(hasFlag(args, 'check') ? 'To grant it, run "microteia grant-signing", or:' : 'This will run:');
|
|
163
|
+
bullets(steps.map(gcloudLine));
|
|
164
|
+
if (before.apiEnabled === null || before.granted === null) {
|
|
165
|
+
warn('part of the current state could not be read; running a step that is already done changes nothing.');
|
|
166
|
+
}
|
|
167
|
+
if (hasFlag(args, 'check'))
|
|
168
|
+
return;
|
|
169
|
+
blank();
|
|
170
|
+
if (!hasFlag(args, 'yes')) {
|
|
171
|
+
if (!process.stdin.isTTY) {
|
|
172
|
+
throw new Error('Not running in a terminal. Re-run with --yes to apply, or run the commands above.');
|
|
173
|
+
}
|
|
174
|
+
if (!(await confirm('Apply these changes?'))) {
|
|
175
|
+
info('Nothing was changed.');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
for (const step of steps)
|
|
181
|
+
await run(GCLOUD_BIN, step);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (!isPermissionError(error))
|
|
185
|
+
throw error;
|
|
186
|
+
blank();
|
|
187
|
+
failure('the account signed in to gcloud may not change permissions in this project.');
|
|
188
|
+
info(' It needs Owner, or Service Account Admin plus Service Usage Admin. Ask someone who has them');
|
|
189
|
+
info(' to run:');
|
|
190
|
+
bullets(steps.map(gcloudLine));
|
|
191
|
+
throw new Error('Session signing was not granted.', { cause: error });
|
|
192
|
+
}
|
|
193
|
+
// IAM é eventualmente consistente: a releitura logo depois pode não refletir a concessão ainda.
|
|
194
|
+
const after = await checkSessionSigning(projectId, region, gcloudCapture);
|
|
195
|
+
blank();
|
|
196
|
+
if (after.apiEnabled !== false && after.granted !== false) {
|
|
197
|
+
success('the functions can sign sessions');
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
warn('the change was accepted but does not show yet — IAM can take a minute. Check again with');
|
|
201
|
+
warn(' microteia grant-signing --check');
|
|
202
|
+
}
|
|
203
|
+
};
|
package/lib/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { unpack } from './console/unpack.js';
|
|
|
11
11
|
import { deploy } from './console/deploy.js';
|
|
12
12
|
import { provision } from './console/provision.js';
|
|
13
13
|
import { exportCatalog } from './console/exportCatalog.js';
|
|
14
|
+
import { grantSigning } from './console/sessionSigning.js';
|
|
14
15
|
import { pluginInit } from './console/pluginWorkspace.js';
|
|
15
16
|
import { versionReport } from './console/versions.js';
|
|
16
17
|
import { newerThan } from './console/channel.js';
|
|
@@ -50,6 +51,13 @@ const COMMANDS = [
|
|
|
50
51
|
audience: 'office',
|
|
51
52
|
execute: provision,
|
|
52
53
|
},
|
|
54
|
+
{
|
|
55
|
+
name: 'grant-signing',
|
|
56
|
+
usage: 'microteia grant-signing [--check] [--yes]',
|
|
57
|
+
summary: 'Let the functions sign sessions (portal identity providers, two-step verification).',
|
|
58
|
+
audience: 'office',
|
|
59
|
+
execute: grantSigning,
|
|
60
|
+
},
|
|
53
61
|
{
|
|
54
62
|
name: 'export-catalog',
|
|
55
63
|
usage: 'microteia export-catalog --tenant <tenantId> [--output <path>]',
|
package/lib/kernel/manifest.js
CHANGED
|
@@ -91,6 +91,70 @@ export const MANUAL_PREREQUISITES = [
|
|
|
91
91
|
+ 'with the credential it was given and refuses everything else.',
|
|
92
92
|
optional: true,
|
|
93
93
|
},
|
|
94
|
+
{
|
|
95
|
+
id: 'portal-identity-signing',
|
|
96
|
+
title: 'Let the functions sign, if an organization signs portal guests in with an identity provider, requires two-step verification or gives guests short-lived document links',
|
|
97
|
+
detail: 'Two features open a session the server mints with createCustomToken, which signs with the service '
|
|
98
|
+
+ 'account the functions run as: a portal guest who signs in through an OpenID Connect provider '
|
|
99
|
+
+ 'configured in Settings > Portal > Sign-in, and a member who confirms the code of two-step '
|
|
100
|
+
+ 'verification (Organization > Security) — the session that carries the proof. Without the '
|
|
101
|
+
+ 'permission the provider confirms the guest, or the code is right, and entering still fails, with '
|
|
102
|
+
+ '"iam.serviceAccounts.signBlob" denied in the log of completePortalSignIn or secondFactor. An '
|
|
103
|
+
+ 'organization cannot lock itself out by turning two-step verification on before this is done: '
|
|
104
|
+
+ 'saving a rule that reaches the person saving it requires that person to have proved the code in '
|
|
105
|
+
+ 'that session, which already needs the signature. A third feature signs with the same account: a '
|
|
106
|
+
+ 'portal screen that declares documentLinks hands guests signed links that expire in minutes, and '
|
|
107
|
+
+ 'without the permission the guest cannot open its documents ("signBlob" denied in the log of '
|
|
108
|
+
+ 'getPortalDocumentUrl). With Console 2.31.0 or newer, run microteia '
|
|
109
|
+
+ 'grant-signing: it reads the account the functions actually run as, shows what it would change and '
|
|
110
|
+
+ 'asks before changing it (--check only reads), and microteia deploy reports when it is missing. By '
|
|
111
|
+
+ 'hand: enable the IAM Service Account Credentials API '
|
|
112
|
+
+ '(gcloud services enable iamcredentials.googleapis.com --project=<projectId>) and grant the '
|
|
113
|
+
+ 'runtime service account the Service Account Token Creator role on itself: gcloud iam '
|
|
114
|
+
+ 'service-accounts add-iam-policy-binding <projectNumber>-compute@developer.gserviceaccount.com '
|
|
115
|
+
+ '--member=serviceAccount:<projectNumber>-compute@developer.gserviceaccount.com '
|
|
116
|
+
+ '--role=roles/iam.serviceAccountTokenCreator --project=<projectId>. Signing in by e-mail link '
|
|
117
|
+
+ 'does not need it.',
|
|
118
|
+
optional: true,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'audit-archive-bucket',
|
|
122
|
+
title: 'Create a bucket with locked retention, if an organization must keep its audit trail as evidence',
|
|
123
|
+
detail: 'The audit trail stays available for 30 days unless an organization raises it, and an organization '
|
|
124
|
+
+ 'can also turn on a daily archive with a hash chain (Audit trail > Retention and archive). The archive '
|
|
125
|
+
+ 'needs a bucket of its own, never the default one: gcloud storage buckets create '
|
|
126
|
+
+ 'gs://<bucket> --project=<projectId> --location=<region> --uniform-bucket-level-access '
|
|
127
|
+
+ '--enable-per-object-retention --retention-period=5y, then gcloud storage buckets update gs://<bucket> '
|
|
128
|
+
+ '--lock-retention-period (irreversible: from then on nobody, project owners included, can delete or '
|
|
129
|
+
+ 'overwrite an archived day before it is five years old, or shorten the period). Use the longest '
|
|
130
|
+
+ 'period any organization here is legally bound to; each organization can lock its own days for '
|
|
131
|
+
+ 'longer. Grant the functions\' runtime service account roles/storage.admin on THAT bucket only (it '
|
|
132
|
+
+ 'reads the bucket\'s retention settings and sets each day\'s retention, which the object roles '
|
|
133
|
+
+ 'do not cover), write AUDIT_ARCHIVE_BUCKET=<bucket> in .env.<projectId> '
|
|
134
|
+
+ 'and run microteia provision. What it protects against, and what it does not: a locked bucket stops '
|
|
135
|
+
+ 'deletion and rewriting of what was already archived; the chain makes any change visible on '
|
|
136
|
+
+ 'verification. A project owner can still alter or delete an audit line in Firestore BEFORE its day '
|
|
137
|
+
+ 'is exported (the export runs hourly, about an hour after midnight UTC), turn the archive off (that '
|
|
138
|
+
+ 'itself is a CRITICAL audit entry, and the gap shows in the chain), or delete the whole project. '
|
|
139
|
+
+ 'Against a rewrite of an unlocked bucket, only the last day\'s hash kept OUTSIDE the project proves '
|
|
140
|
+
+ 'anything — the organization can copy it from the archive screen. Without this setting nothing '
|
|
141
|
+
+ 'changes for anyone.',
|
|
142
|
+
optional: true,
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'self-service-tenants',
|
|
146
|
+
title: 'Stop sign-up from opening an organization, if this installation is shared',
|
|
147
|
+
detail: 'By default every new account gets an organization of its own, with that person as the owner. In an '
|
|
148
|
+
+ 'installation shared by several organizations — each one created on purpose — that lets anyone who '
|
|
149
|
+
+ 'can create an account open one. Write SELF_SERVICE_TENANTS=false in .env.<projectId> and run '
|
|
150
|
+
+ 'microteia provision: from then on a new account waits to be added to an organization, and only the '
|
|
151
|
+
+ 'installation administrator (SYSTEM) opens organizations, in Access management > Organizations, '
|
|
152
|
+
+ 'naming an owner who already has an account. The first account of the installation gets one '
|
|
153
|
+
+ 'anyway. Any value other than empty, true or false is read as false. Two-step verification is '
|
|
154
|
+
+ 'required per organization and does not reach the installation administrator: protect SYSTEM '
|
|
155
|
+
+ 'accounts with a sign-in provider that enforces its own second factor.',
|
|
156
|
+
optional: true,
|
|
157
|
+
},
|
|
94
158
|
];
|
|
95
159
|
/**
|
|
96
160
|
* Os nomes das Cloud Functions saem de `functions/src/index.ts`, a mesma fonte e o mesmo parse que
|
package/lib/shared/paths.d.ts
CHANGED
|
@@ -87,4 +87,4 @@ export declare const consoleRoot: string;
|
|
|
87
87
|
* de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
|
|
88
88
|
* política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
|
|
89
89
|
*/
|
|
90
|
-
export declare const CLI_VERSION = "2.
|
|
90
|
+
export declare const CLI_VERSION = "2.31.1";
|
package/lib/shared/paths.js
CHANGED
|
@@ -94,4 +94,4 @@ export const consoleRoot = resolve(here, '..', '..');
|
|
|
94
94
|
* de código nunca chegou a quem instala. Este bloco fica sendo o que ele já era antes de crescer: a
|
|
95
95
|
* política de semver da ferramenta, mais o registro do que motivou as versões até a `2.5.0`.
|
|
96
96
|
*/
|
|
97
|
-
export const CLI_VERSION = '2.
|
|
97
|
+
export const CLI_VERSION = '2.31.1';
|
|
@@ -93,6 +93,7 @@ const commandRows = () => [
|
|
|
93
93
|
'| `microteia deploy [--only …] [--prune] [--all]` | Publishes what is in place: `rules`, `indexes`, `functions`, `hosting`. A Cloud Function the project already runs with this exact code is not deployed again; `--all` deploys every one of them anyway. `--prune` also deletes functions left behind in a region this version no longer uses |',
|
|
94
94
|
'| `microteia provision [--prune] [--all]` | Gives installed extensions the functions and indexes they ask for |',
|
|
95
95
|
'| `microteia connect --project <id>` | Binds this installation to a Firebase project you own |',
|
|
96
|
+
'| `microteia grant-signing [--check] [--yes]` | Lets the functions sign sessions — needed by portal sign-in through an identity provider and by two-step verification. Reads the account the functions run as, shows what it would change, and asks before enabling the IAM Service Account Credentials API and granting the account Service Account Token Creator on itself. `--check` only reads |',
|
|
96
97
|
'| `microteia plugin-init` | Prepares `extensions/` for authoring in TypeScript |',
|
|
97
98
|
'| `microteia export-catalog --tenant <id>` | Writes what an office has — screens, lists, installed extensions — into `extensions/`, so the authoring tools can see it. Reads only; with no `--tenant` it lists the offices. The file is a snapshot and is git-ignored: it carries names the office typed |',
|
|
98
99
|
].join('\n');
|