diff --git a/README.md b/README.md index e77ea25..25cf884 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No | | Security | Parameter Store | Yes (list, create, delete, inspect) | No | No | +| Security | Certificate Manager / Certificates | Yes (list, create, delete, inspect) | No | No | | Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No | | Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No | diff --git a/packages/api/package.json b/packages/api/package.json index 9228d93..a06db3e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -10,6 +10,7 @@ "test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test" }, "dependencies": { + "@aws-sdk/client-acm": "^3.1146.0", "@aws-sdk/client-api-gateway": "^3.1076.0", "@aws-sdk/client-apigatewayv2": "^3.1076.0", "@aws-sdk/client-appconfig": "^3.1076.0", diff --git a/packages/api/src/adapter-aws/AwsCertificatesAdapter.test.ts b/packages/api/src/adapter-aws/AwsCertificatesAdapter.test.ts new file mode 100644 index 0000000..5012908 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCertificatesAdapter.test.ts @@ -0,0 +1,164 @@ +import {describe, expect, test} from 'bun:test' +import { + type ACMClient, + DeleteCertificateCommand, + DescribeCertificateCommand, + ExportCertificateCommand, + GetCertificateCommand, + ImportCertificateCommand, + ListCertificatesCommand, + RequestCertificateCommand, + RevokeCertificateCommand, +} from '@aws-sdk/client-acm' +import {AwsCertificatesAdapter} from './AwsCertificatesAdapter' +import {ValidationError} from '../cloud-spi/errors' + +type SendResult = Record + +function stubAcm(handler: (command: object) => SendResult | Promise) { + const sent: object[] = [] + const client = { + async send(command: object) { + sent.push(command) + return handler(command) + }, + } as unknown as ACMClient + return {client, sent} +} + +function awsError(name: string, status = 400): Error { + return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}}) +} + +const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba' +const ARN = `arn:aws:acm:us-east-1:000000000000:certificate/${ID}` +const detail = { + CertificateArn: ARN, + DomainName: 'notes.thecloud.localhost', + SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost'], + DomainValidationOptions: [{ + DomainName: 'notes.thecloud.localhost', + ValidationStatus: 'SUCCESS', + ValidationMethod: 'DNS', + ResourceRecord: {Name: '_a1.notes.thecloud.localhost.', Type: 'CNAME', Value: '_e8.acm-validations.aws.'}, + }], + Status: 'ISSUED', + Type: 'AMAZON_ISSUED', + KeyAlgorithm: 'RSA-2048', + Issuer: 'CN=Floci Local CA', + CreatedAt: new Date('2026-10-25T16:28:12Z'), + NotAfter: new Date('2027-10-25T16:28:12Z'), + InUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1'], + Options: {Export: 'ENABLED', CertificateTransparencyLoggingPreference: 'ENABLED'}, +} + +function adapterWith(handler: (command: object) => SendResult | Promise) { + const stub = stubAcm(handler) + return {...stub, adapter: new AwsCertificatesAdapter(stub.client, '000000000000', 'us-east-1')} +} + +describe('AwsCertificatesAdapter', () => { + test('lists every key type across pages and searches the alternative names too', async () => { + const {adapter, sent} = adapterWith((command) => { + if (!(command instanceof ListCertificatesCommand)) return {} + return command.input.NextToken + ? {CertificateSummaryList: [{CertificateArn: ARN.replace(ID, '11111111-2222-3333-4444-555555555555'), DomainName: 'shop.test', Type: 'IMPORTED', Status: 'ISSUED', InUse: false}]} + : {CertificateSummaryList: [{CertificateArn: ARN, DomainName: 'notes.thecloud.localhost', SubjectAlternativeNameSummaries: ['notes.thecloud.localhost', 'api.thecloud.localhost'], Type: 'AMAZON_ISSUED', Status: 'ISSUED', KeyAlgorithm: 'EC-prime256v1', InUse: true}], NextToken: 'page-2'} + }) + + const all = await adapter.list() + expect(all.map((certificate) => [certificate.id, certificate.name, certificate.metadata.certificateType, certificate.metadata.inUse])).toEqual([ + [ID, 'notes.thecloud.localhost', 'Amazon issued', 'Yes'], + ['11111111-2222-3333-4444-555555555555', 'shop.test', 'Imported', 'No'], + ]) + // Without Includes, AWS leaves EC certificates out of the list. + expect((sent[0] as ListCertificatesCommand).input.Includes?.keyTypes).toContain('EC_prime256v1') + expect((await adapter.list({search: 'api.thecloud'})).map((certificate) => certificate.id)).toEqual([ID]) + }) + + test('describes a certificate with its users and the DNS records validation asks for', async () => { + const {adapter, sent} = adapterWith(() => ({Certificate: detail})) + + const certificate = await adapter.get(ID) + expect((sent[0] as DescribeCertificateCommand).input.CertificateArn).toBe(ARN) + expect(certificate?.metadata).toMatchObject({ + inUse: 'Yes', + inUseBy: detail.InUseBy, + exportable: true, + issuer: 'CN=Floci Local CA', + notAfter: '2027-10-25T16:28:12.000Z', + domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}], + }) + }) + + test('a missing certificate is null, and an id that is not a UUID never reaches ACM', async () => { + const {adapter, sent} = adapterWith(() => { throw awsError('ResourceNotFoundException') }) + expect(await adapter.get(ID)).toBeNull() + await expect(adapter.get('../other')).rejects.toBeInstanceOf(ValidationError) + expect(sent).toHaveLength(1) + }) + + test('requests a certificate for the domain and its extra names', async () => { + const {adapter, sent} = adapterWith((command) => command instanceof RequestCertificateCommand ? {CertificateArn: ARN} : {Certificate: detail}) + + const created = await adapter.create({values: {source: 'request', domainName: 'Notes.TheCloud.localhost', subjectAlternativeNames: 'api.thecloud.localhost\nnotes.thecloud.localhost\n*.thecloud.localhost', keyAlgorithm: 'EC_prime256v1', export: 'ENABLED'}}) + expect(created.id).toBe(ID) + expect((sent[0] as RequestCertificateCommand).input).toEqual({ + DomainName: 'notes.thecloud.localhost', + SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost', '*.thecloud.localhost'], + ValidationMethod: 'DNS', + KeyAlgorithm: 'EC_prime256v1', + Options: {Export: 'ENABLED'}, + }) + }) + + test('refuses a request ACM would reject before calling it', async () => { + const {adapter, sent} = adapterWith(() => ({})) + await expect(adapter.create({values: {source: 'request', domainName: 'not a domain'}})).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.create({values: {source: 'request', domainName: 'a.test', subjectAlternativeNames: 'b..test'}})).rejects.toThrow('b..test is not a domain name') + await expect(adapter.create({values: {source: 'request', domainName: 'a.test', keyAlgorithm: 'RSA_8192'}})).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.create({values: {source: 'copy', domainName: 'a.test'}})).rejects.toBeInstanceOf(ValidationError) + expect(sent).toHaveLength(0) + }) + + test('imports PEM text as bytes, with the chain when given', async () => { + const {adapter, sent} = adapterWith((command) => command instanceof ImportCertificateCommand ? {CertificateArn: ARN} : {Certificate: {...detail, Type: 'IMPORTED'}}) + const certificate = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----' + const privateKey = '-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----' + + const created = await adapter.create({values: {source: 'import', certificate, privateKey, certificateChain: certificate}}) + expect(created.metadata.certificateType).toBe('Imported') + const input = (sent[0] as ImportCertificateCommand).input + expect(new TextDecoder().decode(input.Certificate)).toBe(certificate) + expect(new TextDecoder().decode(input.PrivateKey)).toBe(privateKey) + expect(input.CertificateChain).toBeDefined() + + await expect(adapter.create({values: {source: 'import', certificate, privateKey: 'secret'}})).rejects.toThrow('privateKey must be PEM text') + }) + + test('downloads, exports and revokes by the rebuilt ARN', async () => { + const {adapter, sent} = adapterWith((command) => { + if (command instanceof GetCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN'} + if (command instanceof ExportCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN', PrivateKey: 'ENCRYPTED'} + if (command instanceof RevokeCertificateCommand) return {CertificateArn: ARN} + return {Certificate: {...detail, Status: 'REVOKED', RevocationReason: 'SUPERSEDED'}} + }) + + expect(await adapter.getCertificatePem(ID)).toEqual({certificate: 'CERT', certificateChain: 'CHAIN'}) + expect(await adapter.exportCertificate(ID, 'open-sesame')).toEqual({certificate: 'CERT', certificateChain: 'CHAIN', privateKey: 'ENCRYPTED'}) + expect(new TextDecoder().decode((sent[1] as ExportCertificateCommand).input.Passphrase)).toBe('open-sesame') + const revoked = await adapter.revokeCertificate(ID, 'SUPERSEDED') + expect(revoked.status).toBe('REVOKED') + expect((sent[2] as RevokeCertificateCommand).input).toEqual({CertificateArn: ARN, RevocationReason: 'SUPERSEDED'}) + + await expect(adapter.exportCertificate(ID, 'abc')).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.exportCertificate(ID, 'has$dollar')).rejects.toBeInstanceOf(ValidationError) + await expect(adapter.revokeCertificate(ID, 'BORED' as never)).rejects.toBeInstanceOf(ValidationError) + }) + + test('deletes by ARN and lets ACM refuse a certificate that is in use', async () => { + const {adapter, sent} = adapterWith(() => { throw awsError('ResourceInUseException') }) + await expect(adapter.delete(ID)).rejects.toThrow('ResourceInUseException') + expect((sent[0] as DeleteCertificateCommand).input.CertificateArn).toBe(ARN) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsCertificatesAdapter.ts b/packages/api/src/adapter-aws/AwsCertificatesAdapter.ts new file mode 100644 index 0000000..1c6a03f --- /dev/null +++ b/packages/api/src/adapter-aws/AwsCertificatesAdapter.ts @@ -0,0 +1,290 @@ +import { + type ACMClient, + type CertificateDetail, + type CertificateSummary, + DeleteCertificateCommand, + DescribeCertificateCommand, + ExportCertificateCommand, + GetCertificateCommand, + ImportCertificateCommand, + type KeyAlgorithm, + ListCertificatesCommand, + RequestCertificateCommand, + RevokeCertificateCommand, +} from '@aws-sdk/client-acm' +import { + awsCertificatesSchema, + CERTIFICATE_DOMAIN_PATTERN, + CERTIFICATE_KEY_ALGORITHMS, + CERTIFICATE_REVOCATION_REASONS, + CERTIFICATE_VALIDATION_METHODS, +} from '../cloud-spi/certificatesSchema' +import {RuntimeError, ValidationError} from '../cloud-spi/errors' +import type { + CertificatePem, + CertificateRevocationReason, + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ExportedCertificate, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +/** + * ListCertificates returns only RSA certificates unless asked for other key + * types, so a list without this would silently leave out every EC certificate. + */ +const ALL_KEY_TYPES: KeyAlgorithm[] = ['RSA_1024', 'RSA_2048', 'RSA_3072', 'RSA_4096', 'EC_prime256v1', 'EC_secp384r1', 'EC_secp521r1'] +/** ACM's limit on names per certificate, the domain name included. */ +const MAX_NAMES = 100 +const CERTIFICATE_TYPE_LABEL: Record = {AMAZON_ISSUED: 'Amazon issued', IMPORTED: 'Imported', PRIVATE: 'Private'} + +/** + * Talks to AWS Certificate Manager, verified against Floci core (community, `latest`). + * + * Notes from probing the runtime: + * - Requested certificates are signed by the runtime's own local CA and issued at + * once (its validation wait defaults to 0), but DescribeCertificate still lists + * the CNAME records DNS validation would ask for, as AWS does. + * - DeleteCertificate refuses with ResourceInUseException while a load balancer, + * distribution or API domain uses the certificate, as on AWS. + * + * Resources are keyed by the certificate's UUID, the last part of its ARN, because + * an ARN's slash cannot travel in a URL path segment; the ARN is rebuilt from the + * account and region this adapter serves. + */ +export class AwsCertificatesAdapter implements CloudServiceAdapter { + readonly cloud = 'aws' as const + readonly service = 'certificates' as const + + constructor( + private readonly acm: ACMClient, + private readonly accountId: string, + private readonly region = process.env.AWS_REGION || 'us-east-1', + ) {} + + schema(): ServiceSchema { + return awsCertificatesSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const summaries: CertificateSummary[] = [] + let next: string | undefined + do { + const res = await this.acm.send(new ListCertificatesCommand({Includes: {keyTypes: ALL_KEY_TYPES}, MaxItems: 1000, ...(next ? {NextToken: next} : {})})) + summaries.push(...(res.CertificateSummaryList ?? [])) + next = res.NextToken + } while (next) + + const search = query.search?.trim().toLowerCase() + return summaries.map((summary) => this.fromSummary(summary)).filter((certificate) => !search + || certificate.name.toLowerCase().includes(search) + || certificate.id.includes(search) + || (certificate.metadata.subjectAlternativeNames as string[]).some((name) => name.toLowerCase().includes(search))) + } + + async get(id: string): Promise { + try { + const res = await this.acm.send(new DescribeCertificateCommand({CertificateArn: this.arn(id)})) + return res.Certificate ? this.fromDetail(res.Certificate) : null + } catch (error) { + if (errorName(error) === 'ResourceNotFoundException') return null + throw error + } + } + + async create(input: CreateResourceInput): Promise { + const source = input.values.source ?? 'request' + const arn = source === 'import' ? await this.importCertificate(input.values) + : source === 'request' ? await this.requestCertificate(input.values) + : null + if (arn === null) throw new ValidationError('source must be request or import') + + const created = await this.get(idFromArn(arn)) + if (!created) throw new RuntimeError(`ACM did not return the new certificate ${arn}`) + return created + } + + async delete(id: string): Promise { + await this.acm.send(new DeleteCertificateCommand({CertificateArn: this.arn(id)})) + } + + async getCertificatePem(id: string): Promise { + const res = await this.acm.send(new GetCertificateCommand({CertificateArn: this.arn(id)})) + if (!res.Certificate) throw new RuntimeError('ACM did not return the certificate') + return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null} + } + + /** + * AWS exports only private certificates and public ones requested as + * exportable; the private key comes back encrypted with the passphrase. + */ + async exportCertificate(id: string, passphrase: string): Promise { + if (typeof passphrase !== 'string' || passphrase.length < 4 || passphrase.length > 128) { + throw new ValidationError('passphrase must be 4 to 128 characters') + } + if (/[#$%]/.test(passphrase)) throw new ValidationError('passphrase must not contain #, $ or %') + + const res = await this.acm.send(new ExportCertificateCommand({CertificateArn: this.arn(id), Passphrase: new TextEncoder().encode(passphrase)})) + if (!res.Certificate || !res.PrivateKey) throw new RuntimeError('ACM did not return the exported certificate') + return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null, privateKey: res.PrivateKey} + } + + async revokeCertificate(id: string, reason: CertificateRevocationReason): Promise { + if (!(CERTIFICATE_REVOCATION_REASONS as readonly string[]).includes(reason)) { + throw new ValidationError(`reason must be one of ${CERTIFICATE_REVOCATION_REASONS.join(', ')}`) + } + await this.acm.send(new RevokeCertificateCommand({CertificateArn: this.arn(id), RevocationReason: reason})) + const revoked = await this.get(id) + if (!revoked) throw new RuntimeError(`ACM did not return the revoked certificate ${id}`) + return revoked + } + + private async requestCertificate(values: Record): Promise { + const domainName = text(values.domainName).toLowerCase() + if (!domainName) throw new ValidationError('domainName is required') + const pattern = new RegExp(CERTIFICATE_DOMAIN_PATTERN) + if (!pattern.test(domainName)) throw new ValidationError('domainName must be a domain name such as example.com or *.example.com') + + const alternatives = [...new Set(text(values.subjectAlternativeNames).split(/[\s,]+/).map((name) => name.toLowerCase()).filter((name) => name && name !== domainName))] + const invalid = alternatives.find((name) => !pattern.test(name)) + if (invalid) throw new ValidationError(`${invalid} is not a domain name`) + if (alternatives.length + 1 > MAX_NAMES) throw new ValidationError(`A certificate covers at most ${MAX_NAMES} names`) + + const validationMethod = oneOf(values.validationMethod, CERTIFICATE_VALIDATION_METHODS, 'validationMethod') + const keyAlgorithm = oneOf(values.keyAlgorithm, CERTIFICATE_KEY_ALGORITHMS, 'keyAlgorithm') + const exportOption = oneOf(values.export, ['DISABLED', 'ENABLED'] as const, 'export') + + const res = await this.acm.send(new RequestCertificateCommand({ + DomainName: domainName, + ...(alternatives.length > 0 ? {SubjectAlternativeNames: [domainName, ...alternatives]} : {}), + ValidationMethod: validationMethod, + KeyAlgorithm: keyAlgorithm, + Options: {Export: exportOption}, + })) + if (!res.CertificateArn) throw new RuntimeError('ACM did not return the requested certificate') + return res.CertificateArn + } + + private async importCertificate(values: Record): Promise { + const certificate = pem(values.certificate, 'certificate', 'CERTIFICATE') + const privateKey = pem(values.privateKey, 'privateKey', 'PRIVATE KEY') + const chain = text(values.certificateChain) + if (chain) pem(chain, 'certificateChain', 'CERTIFICATE') + + const encode = (value: string) => new TextEncoder().encode(value) + const res = await this.acm.send(new ImportCertificateCommand({ + Certificate: encode(certificate), + PrivateKey: encode(privateKey), + ...(chain ? {CertificateChain: encode(chain)} : {}), + })) + if (!res.CertificateArn) throw new RuntimeError('ACM did not return the imported certificate') + return res.CertificateArn + } + + private arn(id: string): string { + if (!/^[0-9a-f-]{36}$/i.test(id)) throw new ValidationError('certificate id must be the UUID at the end of its ARN') + return `arn:aws:acm:${this.region}:${this.accountId}:certificate/${id}` + } + + private fromSummary(summary: CertificateSummary): CloudResource { + const arn = summary.CertificateArn ?? '' + return { + id: idFromArn(arn), + name: summary.DomainName ?? '', + cloud: 'aws', + service: 'certificates', + type: 'certificate', + region: arn.split(':')[3] || this.region, + createdAt: iso(summary.CreatedAt ?? summary.ImportedAt), + status: summary.Status ?? null, + metadata: { + arn, + certificateType: CERTIFICATE_TYPE_LABEL[summary.Type ?? ''] ?? summary.Type ?? null, + inUse: summary.InUse ? 'Yes' : 'No', + keyAlgorithm: summary.KeyAlgorithm ?? null, + notAfter: iso(summary.NotAfter), + subjectAlternativeNames: summary.SubjectAlternativeNameSummaries ?? [], + }, + } + } + + private fromDetail(detail: CertificateDetail): CloudResource { + const arn = detail.CertificateArn ?? '' + const inUseBy = detail.InUseBy ?? [] + return { + id: idFromArn(arn), + name: detail.DomainName ?? '', + cloud: 'aws', + service: 'certificates', + type: 'certificate', + region: arn.split(':')[3] || this.region, + createdAt: iso(detail.CreatedAt ?? detail.ImportedAt), + status: detail.Status ?? null, + metadata: { + arn, + certificateType: CERTIFICATE_TYPE_LABEL[detail.Type ?? ''] ?? detail.Type ?? null, + inUse: inUseBy.length > 0 ? 'Yes' : 'No', + inUseBy, + keyAlgorithm: detail.KeyAlgorithm ?? null, + signatureAlgorithm: detail.SignatureAlgorithm ?? null, + notBefore: iso(detail.NotBefore), + notAfter: iso(detail.NotAfter), + issuedAt: iso(detail.IssuedAt), + importedAt: iso(detail.ImportedAt), + revokedAt: iso(detail.RevokedAt), + revocationReason: detail.RevocationReason ?? null, + failureReason: detail.FailureReason ?? null, + subject: detail.Subject ?? null, + issuer: detail.Issuer ?? null, + serial: detail.Serial ?? null, + subjectAlternativeNames: detail.SubjectAlternativeNames ?? [], + exportable: detail.Options?.Export === 'ENABLED', + transparencyLogging: detail.Options?.CertificateTransparencyLoggingPreference ?? null, + renewalEligibility: detail.RenewalEligibility ?? null, + domainValidations: (detail.DomainValidationOptions ?? []).map((validation) => ({ + domainName: validation.DomainName ?? '', + status: validation.ValidationStatus ?? null, + method: validation.ValidationMethod ?? null, + record: validation.ResourceRecord + ? {name: validation.ResourceRecord.Name ?? '', type: validation.ResourceRecord.Type ?? 'CNAME', value: validation.ResourceRecord.Value ?? ''} + : null, + })), + }, + } + } +} + +/** `arn:aws:acm:us-east-1:000000000000:certificate/` — the UUID is what the console keys on. */ +function idFromArn(arn: string): string { + return arn.slice(arn.lastIndexOf('/') + 1) +} + +function iso(value: Date | undefined): string | null { + return value ? value.toISOString() : null +} + +function text(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function pem(value: unknown, field: string, label: string): string { + const body = text(value) + if (!body) throw new ValidationError(`${field} is required`) + if (!new RegExp(`-----BEGIN [A-Z ]*${label}-----`).test(body)) { + throw new ValidationError(`${field} must be PEM text starting with -----BEGIN ${label}-----`) + } + return body +} + +function oneOf(value: unknown, allowed: T, field: string): T[number] { + const raw = text(value) + if (!raw) return allowed[0] as T[number] + if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) + return raw as T[number] +} + +function errorName(error: unknown): string | undefined { + return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index 86a2b13..187566a 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -34,6 +34,7 @@ import { LightsailClient } from "@aws-sdk/client-lightsail"; import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider"; import { Route53Client } from "@aws-sdk/client-route-53"; import { CloudFrontClient } from "@aws-sdk/client-cloudfront"; +import { ACMClient } from "@aws-sdk/client-acm"; const endpoint = process.env.FLOCI_ENDPOINT; const region = process.env.AWS_REGION || "us-east-1"; @@ -99,6 +100,7 @@ export type AwsClients = { cognito: CognitoIdentityProviderClient; route53: Route53Client; cloudfront: CloudFrontClient; + acm: ACMClient; }; export type AwsClientName = keyof AwsClients; @@ -148,6 +150,7 @@ function buildClients(accountId: string): AwsClients { cognito: new CognitoIdentityProviderClient(base), route53: new Route53Client(base), cloudfront: new CloudFrontClient(base), + acm: new ACMClient(base), }; } diff --git a/packages/api/src/cloud-spi/certificatesSchema.ts b/packages/api/src/cloud-spi/certificatesSchema.ts new file mode 100644 index 0000000..187b515 --- /dev/null +++ b/packages/api/src/cloud-spi/certificatesSchema.ts @@ -0,0 +1,154 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +/** A domain name, optionally a wildcard (`*.example.com`), as RequestCertificate accepts it. */ +export const CERTIFICATE_DOMAIN_PATTERN = '^(\\*\\.)?(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$' + +/** The key algorithms RequestCertificate accepts for a public certificate. */ +export const CERTIFICATE_KEY_ALGORITHMS = ['RSA_2048', 'EC_prime256v1', 'EC_secp384r1'] as const +export const CERTIFICATE_VALIDATION_METHODS = ['DNS', 'EMAIL'] as const +/** RevokeCertificate's reasons, as the API names them. */ +export const CERTIFICATE_REVOCATION_REASONS = [ + 'UNSPECIFIED', + 'KEY_COMPROMISE', + 'CA_COMPROMISE', + 'AFFILIATION_CHANGED', + 'SUPERSEDED', + 'CESSATION_OF_OPERATION', + 'PRIVILEGE_WITHDRAWN', +] as const + +const certificateColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Domain name'}, + {name: 'status', label: 'Status', format: 'badge'}, + {name: 'certificateType', label: 'Type', path: 'metadata.certificateType'}, + {name: 'inUse', label: 'In use', path: 'metadata.inUse'}, + {name: 'keyAlgorithm', label: 'Key algorithm', path: 'metadata.keyAlgorithm'}, + {name: 'notAfter', label: 'Expires', path: 'metadata.notAfter', format: 'datetime', emptyText: '—'}, + {name: 'id', label: 'Certificate ID', format: 'code'}, +] + +const certificateFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}] + +const requested = {field: 'source', values: ['request']} +const imported = {field: 'source', values: ['import']} + +export function awsCertificatesSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'certificates', + displayName: 'AWS Certificate Manager', + fields: [ + { + name: 'source', + label: 'Certificate type', + type: 'select', + required: true, + defaultValue: 'request', + options: [ + {label: 'Request a public certificate', value: 'request'}, + {label: 'Import a certificate', value: 'import'}, + ], + span: true, + }, + { + name: 'domainName', + label: 'Domain name', + type: 'text', + required: false, + requiredWhen: {field: 'source', equals: 'request'}, + visibleWhen: requested, + description: 'For example notes.thecloud.localhost, or *.thecloud.localhost for every subdomain. TheCloud signs it with its own local certificate authority.', + validation: {pattern: CERTIFICATE_DOMAIN_PATTERN, message: 'Use a domain name such as example.com or *.example.com'}, + }, + { + name: 'subjectAlternativeNames', + label: 'Additional names', + type: 'textarea', + required: false, + visibleWhen: requested, + description: 'Other names the certificate covers, one per line.', + }, + { + name: 'validationMethod', + label: 'Validation method', + type: 'select', + required: false, + defaultValue: 'DNS', + visibleWhen: requested, + description: 'How ACM proves you own the domain. TheCloud issues the certificate without checking, and still shows the DNS records AWS would ask for.', + options: CERTIFICATE_VALIDATION_METHODS.map((value) => ({label: value, value})), + }, + { + name: 'keyAlgorithm', + label: 'Key algorithm', + type: 'select', + required: false, + defaultValue: 'RSA_2048', + visibleWhen: requested, + options: CERTIFICATE_KEY_ALGORITHMS.map((value) => ({label: value.replace('_', ' '), value})), + }, + { + name: 'export', + label: 'Exportable', + type: 'select', + required: false, + defaultValue: 'DISABLED', + visibleWhen: requested, + description: 'An exportable certificate can be downloaded with its private key, to use on servers outside AWS. This is chosen once and cannot be changed later.', + options: [ + {label: 'No', value: 'DISABLED'}, + {label: 'Yes, allow export', value: 'ENABLED'}, + ], + }, + { + name: 'certificate', + label: 'Certificate body (PEM)', + type: 'textarea', + required: false, + requiredWhen: {field: 'source', equals: 'import'}, + visibleWhen: imported, + span: true, + description: 'Starts with -----BEGIN CERTIFICATE-----.', + }, + { + name: 'privateKey', + label: 'Private key (PEM)', + type: 'textarea', + required: false, + requiredWhen: {field: 'source', equals: 'import'}, + visibleWhen: imported, + span: true, + description: 'The unencrypted key that matches the certificate.', + }, + { + name: 'certificateChain', + label: 'Certificate chain (PEM)', + type: 'textarea', + required: false, + visibleWhen: imported, + span: true, + description: 'The intermediate certificates, if the certificate is not self-signed.', + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List certificates', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Request or import a certificate', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete certificate', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect certificate', enabled: true, status: 'available', runtimeRequired: true}, + ], + certificateActions: [ + {name: 'download', label: 'Download the certificate and chain', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'export', label: 'Export with the private key', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'revoke', label: 'Revoke certificate', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: certificateFilters, + columns: certificateColumns, + } +} + +export function certificatesSchemaFor(cloud: CloudProvider): ServiceSchema | null { + return cloud === 'aws' ? awsCertificatesSchema() : null +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index c297a41..f0ca116 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -239,6 +239,13 @@ export const SERVICE_CATALOG = { // AWS retains its dedicated page while Azure and GCP use Cloud Explorer. routeByCloud: {aws: '/secretsmanager'}, }, + certificates: { + displayName: 'Certificates', + displayNameByCloud: {aws: 'Certificate Manager'}, + iconKey: 'certificates', + group: 'Security', + order: 35, + }, cdn: { displayName: 'CDN', displayNameByCloud: {aws: 'CloudFront'}, diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 3518808..9fdc786 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -186,6 +186,7 @@ export type UserPoolActionName = | 'describeClient' | 'updateClientOAuth' export type DistributionActionName = 'setEnabled' | 'createInvalidation' +export type CertificateActionName = 'download' | 'export' | 'revoke' export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions' export type HttpApiActionName = | 'describe' @@ -245,6 +246,7 @@ export interface ServiceSchema { kubernetesActions?: CapabilitySchema[] userPoolActions?: CapabilitySchema[] distributionActions?: CapabilitySchema[] + certificateActions?: CapabilitySchema[] monitoringActions?: CapabilitySchema[] /** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */ httpApiActions?: CapabilitySchema[] @@ -515,6 +517,26 @@ export interface AlarmHistoryItem { summary: string } +/** A certificate and its chain as PEM text; never the private key. */ +export interface CertificatePem { + certificate: string + certificateChain: string | null +} + +/** An exported certificate: the private key is PEM, encrypted with the caller's passphrase. */ +export interface ExportedCertificate extends CertificatePem { + privateKey: string +} + +export type CertificateRevocationReason = + | 'UNSPECIFIED' + | 'KEY_COMPROMISE' + | 'CA_COMPROMISE' + | 'AFFILIATION_CHANGED' + | 'SUPERSEDED' + | 'CESSATION_OF_OPERATION' + | 'PRIVILEGE_WITHDRAWN' + export interface DistributionInvalidation { id: string status: string @@ -1182,6 +1204,9 @@ export interface CloudServiceAdapter { setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise createDistributionInvalidation?(distributionId: string, paths: string[]): Promise + getCertificatePem?(certificateId: string): Promise + exportCertificate?(certificateId: string, passphrase: string): Promise + revokeCertificate?(certificateId: string, reason: CertificateRevocationReason): Promise updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise /** Clears the provider's locally captured email inbox, if it exposes one. */ clearEmailInbox?(): Promise diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index abbe6d6..2cacbcf 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test' import {createCloudAdapterRegistry} from './cloudProxy' import {checkChildCapabilities} from './cloud-spi/childCapabilities' import {isServiceType} from './cloud-spi/serviceCatalog' -import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' +import type {CloudProvider, CloudServiceAdapter, CloudServiceType, CertificateActionName, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' /** * Guards the schema-to-implementation contract for every registered adapter. @@ -75,6 +75,12 @@ const METHOD_FOR_DISTRIBUTION_ACTION: Record = { + download: 'getCertificatePem', + export: 'exportCertificate', + revoke: 'revokeCertificate', +} + const METHOD_FOR_USER_POOL_ACTION: Record = { updateUser: 'updateUserPoolUser', setUserGroups: 'setUserPoolUserGroups', @@ -188,6 +194,15 @@ describe('registered adapters honour their schema', () => { ).toBe('function') } + const certificateClaims = (adapter.schema().capabilities?.certificateActions ?? []).filter((capability) => capability.status === 'available') + for (const capability of certificateClaims) { + const method = METHOD_FOR_CERTIFICATE_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as available but has no ${String(method)}()`, + ).toBe('function') + } + const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available') for (const capability of distributionClaims) { const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name] @@ -210,6 +225,7 @@ describe('registered adapters honour their schema', () => { ...(adapter.schema().capabilities?.kubernetesActions ?? []), ...(adapter.schema().capabilities?.userPoolActions ?? []), ...(adapter.schema().capabilities?.distributionActions ?? []), + ...(adapter.schema().capabilities?.certificateActions ?? []), ...(adapter.schema().capabilities?.monitoringActions ?? []), ...(adapter.schema().capabilities?.httpApiActions ?? []), ...(adapter.schema().capabilities?.collectionActions ?? []), diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 48ebcaf..c1f4d4d 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter' import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter' import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter' +import {AwsCertificatesAdapter} from './adapter-aws/AwsCertificatesAdapter' import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' @@ -103,6 +104,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsUserPoolsAdapter(clients.cognito), new AwsDnsAdapter(clients.route53), new AwsCdnAdapter(clients.cloudfront, clients.s3), + new AwsCertificatesAdapter(clients.acm, resolveAccountId(accountId)), new AwsMonitoringAdapter(clients.cloudwatch), new AzureStorageAdapter(), new AzureServiceBusAdapter(), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 73458ae..caa4d5f 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -18,6 +18,7 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s import {awsSesEmailSchema} from '../cloud-spi/emailSchema' import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema' import {awsCdnSchema} from '../cloud-spi/cdnSchema' +import {awsCertificatesSchema} from '../cloud-spi/certificatesSchema' import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' @@ -823,6 +824,34 @@ describe('cloud schema routes', () => { expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]]) }) + test('downloads, exports and revokes a certificate through the ACM adapter', async () => { + const calls: unknown[] = [] + const app = appWithRoutes([mockAdapter('aws', { + service: 'certificates', + schema: awsCertificatesSchema, + getCertificatePem: async (id) => { + calls.push(['pem', id]) + return {certificate: 'CERT', certificateChain: null} + }, + exportCertificate: async (id, passphrase) => { + calls.push(['export', id, passphrase]) + return {certificate: 'CERT', certificateChain: null, privateKey: 'KEY'} + }, + revokeCertificate: async (id, reason) => { + calls.push(['revoke', id, reason]) + return {id, name: 'a.test', cloud: 'aws', service: 'certificates', type: 'certificate', region: null, createdAt: null, status: 'REVOKED', metadata: {}} + }, + })]) + const json = (body: unknown) => ({method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body)}) + const base = '/api/clouds/aws/services/certificates/resources/c1' + + expect(await (await app.request(`${base}/pem`)).json()).toEqual({certificate: 'CERT', certificateChain: null}) + expect((await (await app.request(`${base}/export`, json({passphrase: 'secret'}))).json()).privateKey).toBe('KEY') + const revoked = await app.request(`${base}/revoke`, json({})) + expect((await revoked.json()).status).toBe('REVOKED') + expect(calls).toEqual([['pem', 'c1'], ['export', 'c1', 'secret'], ['revoke', 'c1', 'UNSPECIFIED']]) + }) + test('clears the SES mailbox through the email adapter', async () => { let cleared = false const app = appWithRoutes([mockAdapter('aws', { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 4c43392..a47d753 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -2,6 +2,7 @@ import {Hono} from 'hono' import type {Context} from 'hono' import type { AlarmStateValue, + CertificateRevocationReason, CloudProvider, CloudServiceType, CreateDatabaseSnapshotInput, @@ -525,6 +526,33 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.get('/:cloud/services/certificates/resources/:id/pem', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => c.json(await svc(c).getCertificatePem(cloud, c.req.param('id')))) + }) + + app.post('/:cloud/services/certificates/resources/:id/export', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{passphrase?: string}>() + return c.json(await svc(c).exportCertificate(cloud, c.req.param('id'), body.passphrase as string)) + }) + }) + + app.post('/:cloud/services/certificates/resources/:id/revoke', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{reason?: CertificateRevocationReason}>() + return c.json(await svc(c).revokeCertificate(cloud, c.req.param('id'), body.reason ?? 'UNSPECIFIED')) + }) + }) + app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 81cc3fe..9127cba 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -93,6 +93,9 @@ import type { UserPoolSignInTestResult, UserPoolClientSettings, DistributionInvalidation, + CertificatePem, + CertificateRevocationReason, + ExportedCertificate, AlarmHistoryItem, AlarmStateValue, MetricSeries, @@ -942,6 +945,24 @@ export class CloudProxyService { return adapter.createDistributionInvalidation(distributionId, paths) } + async getCertificatePem(cloud: CloudProvider, certificateId: string): Promise { + const adapter = this.requireAdapter(cloud, 'certificates') + if (!adapter.getCertificatePem) throw new NotSupportedError(`Downloading certificates is not supported for ${cloud}/certificates`) + return adapter.getCertificatePem(certificateId) + } + + async exportCertificate(cloud: CloudProvider, certificateId: string, passphrase: string): Promise { + const adapter = this.requireAdapter(cloud, 'certificates') + if (!adapter.exportCertificate) throw new NotSupportedError(`Exporting certificates is not supported for ${cloud}/certificates`) + return adapter.exportCertificate(certificateId, passphrase) + } + + async revokeCertificate(cloud: CloudProvider, certificateId: string, reason: CertificateRevocationReason): Promise { + const adapter = this.requireAdapter(cloud, 'certificates') + if (!adapter.revokeCertificate) throw new NotSupportedError(`Revoking certificates is not supported for ${cloud}/certificates`) + return adapter.revokeCertificate(certificateId, reason) + } + async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise { const adapter = this.requireAdapter(cloud, 'userpools') if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index be4e9d7..ee0dafc 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -150,6 +150,11 @@ export const apiEndpointKeys = { state: "clouds.services.monitoring.state", actions: "clouds.services.monitoring.actions", }, + certificates: { + pem: "clouds.services.certificates.pem", + export: "clouds.services.certificates.export", + revoke: "clouds.services.certificates.revoke", + }, cdn: { enabled: "clouds.services.cdn.enabled", invalidations: "clouds.services.cdn.invalidations", @@ -926,6 +931,27 @@ export const endpointRegistry: EndpointRegistry = new Map([ [apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}], [apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}], [apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}], + [ + apiEndpointKeys.clouds.certificates.pem, + { + path: "/clouds/:cloud/services/certificates/resources/:id/pem", + method: "GET", + }, + ], + [ + apiEndpointKeys.clouds.certificates.export, + { + path: "/clouds/:cloud/services/certificates/resources/:id/export", + method: "POST", + }, + ], + [ + apiEndpointKeys.clouds.certificates.revoke, + { + path: "/clouds/:cloud/services/certificates/resources/:id/revoke", + method: "POST", + }, + ], [ apiEndpointKeys.clouds.cdn.enabled, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 67c9d36..5dad178 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -33,6 +33,9 @@ import type { UserPoolSignInTestResult, UserPoolClientSettings, DistributionInvalidation, + CertificatePem, + CertificateRevocationReason, + ExportedCertificate, AlarmHistoryItem, AlarmStateValue, MetricSeries, @@ -1679,6 +1682,44 @@ export async function testWebSocketApi(cloud: CloudProvider, apiId: string, inpu return res.data; } +export async function getCertificatePem( + cloud: CloudProvider, + certificateId: string, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.certificates.pem, + requestOptions(cloud, "certificates"), + { cloud, id: certificateId }, + ); + return res.data; +} + +export async function exportCertificate( + cloud: CloudProvider, + certificateId: string, + passphrase: string, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.certificates.export, + requestOptions(cloud, "certificates", { body: { passphrase } }), + { cloud, id: certificateId }, + ); + return res.data; +} + +export async function revokeCertificate( + cloud: CloudProvider, + certificateId: string, + reason: CertificateRevocationReason, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.certificates.revoke, + requestOptions(cloud, "certificates", { body: { reason } }), + { cloud, id: certificateId }, + ); + return res.data; +} + export async function setDistributionEnabled( cloud: CloudProvider, distributionId: string, diff --git a/packages/frontend/src/components/CertificatePanel.test.tsx b/packages/frontend/src/components/CertificatePanel.test.tsx new file mode 100644 index 0000000..4c95352 --- /dev/null +++ b/packages/frontend/src/components/CertificatePanel.test.tsx @@ -0,0 +1,88 @@ +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {render, screen, within} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {beforeEach, expect, test, vi} from 'vitest' +import {CertificatePanel} from './CertificatePanel' +import type {CloudResource} from '@/types/resource' + +const api = vi.hoisted(() => ({ + getCloudResource: vi.fn(), + getCertificatePem: vi.fn(), + exportCertificate: vi.fn(), + revokeCertificate: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => api) +vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'})) + +const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba' +function certificate(metadata: Record = {}, status = 'ISSUED'): CloudResource { + return { + id: ID, name: 'notes.thecloud.localhost', cloud: 'aws', service: 'certificates', type: 'certificate', + region: 'us-east-1', createdAt: null, status, + metadata: { + certificateType: 'Amazon issued', exportable: false, inUseBy: [], + domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}], + ...metadata, + }, + } +} + +function renderPanel(resource: CloudResource) { + api.getCloudResource.mockResolvedValue(resource) + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + return render() +} + +beforeEach(() => { + vi.clearAllMocks() + api.getCertificatePem.mockResolvedValue({certificate: '-----BEGIN CERTIFICATE-----\nLEAF', certificateChain: '-----BEGIN CERTIFICATE-----\nCA'}) + api.exportCertificate.mockResolvedValue({certificate: 'LEAF', certificateChain: null, privateKey: '-----BEGIN ENCRYPTED PRIVATE KEY-----'}) + api.revokeCertificate.mockResolvedValue(certificate({exportable: true}, 'REVOKED')) +}) + +test('shows the validation records and that nothing uses the certificate', async () => { + renderPanel(certificate()) + + expect(await screen.findByText('_a1.notes.thecloud.localhost.')).toBeInTheDocument() + expect(screen.getByText('_e8.acm-validations.aws.')).toBeInTheDocument() + expect(screen.getByText(/Nothing uses this certificate/)).toBeInTheDocument() + // Export and revoke exist only for certificates requested as exportable, as on AWS. + expect(screen.queryByRole('form', {name: 'Export certificate'})).not.toBeInTheDocument() + expect(screen.queryByRole('form', {name: 'Revoke certificate'})).not.toBeInTheDocument() +}) + +test('lists what uses the certificate, since ACM will not delete it then', async () => { + renderPanel(certificate({inUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1']})) + + expect(await screen.findByText('arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1')).toBeInTheDocument() + expect(screen.getByText(/will not delete the certificate/)).toBeInTheDocument() +}) + +test('gets the certificate and chain as downloadable PEM files', async () => { + const user = userEvent.setup() + renderPanel(certificate()) + + await user.click(await screen.findByRole('button', {name: 'Get certificate'})) + expect(await screen.findByRole('textbox', {name: 'Certificate'})).toHaveValue('-----BEGIN CERTIFICATE-----\nLEAF') + expect(screen.getByRole('textbox', {name: 'Certificate chain'})).toHaveValue('-----BEGIN CERTIFICATE-----\nCA') + expect(screen.getByRole('link', {name: /notes.thecloud.localhost.pem/})).toHaveAttribute('download', 'notes.thecloud.localhost.pem') +}) + +test('exports an exportable certificate with the passphrase, then revokes it', async () => { + const user = userEvent.setup() + vi.spyOn(window, 'confirm').mockReturnValue(true) + renderPanel(certificate({exportable: true})) + + const exportForm = await screen.findByRole('form', {name: 'Export certificate'}) + await user.type(within(exportForm).getByLabelText(/Passphrase/), 'open-sesame') + await user.click(within(exportForm).getByRole('button', {name: 'Export'})) + expect(api.exportCertificate).toHaveBeenCalledWith('aws', ID, 'open-sesame') + expect(await screen.findByRole('textbox', {name: 'Private key (encrypted)'})).toHaveValue('-----BEGIN ENCRYPTED PRIVATE KEY-----') + + const revokeForm = screen.getByRole('form', {name: 'Revoke certificate'}) + await user.selectOptions(within(revokeForm).getByLabelText('Reason'), 'SUPERSEDED') + await user.click(within(revokeForm).getByRole('button', {name: 'Revoke'})) + expect(api.revokeCertificate).toHaveBeenCalledWith('aws', ID, 'SUPERSEDED') + expect(await screen.findByText(/Certificate revoked/)).toBeInTheDocument() +}) diff --git a/packages/frontend/src/components/CertificatePanel.tsx b/packages/frontend/src/components/CertificatePanel.tsx new file mode 100644 index 0000000..43fba70 --- /dev/null +++ b/packages/frontend/src/components/CertificatePanel.tsx @@ -0,0 +1,166 @@ +import {Download, RefreshCw} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {useEffect, useState} from 'react' +import {exportCertificate, getCertificatePem, getCloudResource, revokeCertificate} from '@/api/cloudProxyClient' +import {CopyValueButton} from '@/components/CopyValueButton' +import {useAccountId} from '@/lib/accountStore' +import {formatDateTime} from '@/lib/format' +import type {CertificatePem, CertificateRevocationReason, CloudResource, ExportedCertificate} from '@/types/resource' + +type Props = { + resource?: CloudResource + runtimeReachable: boolean +} + +type DomainValidation = {domainName: string; status: string | null; method: string | null; record: {name: string; type: string; value: string} | null} + +const REVOCATION_REASONS: Array<{value: CertificateRevocationReason; label: string}> = [ + {value: 'UNSPECIFIED', label: 'Unspecified'}, + {value: 'KEY_COMPROMISE', label: 'Key compromise'}, + {value: 'SUPERSEDED', label: 'Superseded'}, + {value: 'AFFILIATION_CHANGED', label: 'Affiliation changed'}, + {value: 'CESSATION_OF_OPERATION', label: 'Cessation of operation'}, + {value: 'PRIVILEGE_WITHDRAWN', label: 'Privilege withdrawn'}, + {value: 'CA_COMPROMISE', label: 'CA compromise'}, +] + +export function CertificatePanel({resource, runtimeReachable}: Props) { + const accountId = useAccountId() + const queryClient = useQueryClient() + const [passphrase, setPassphrase] = useState('') + const [reason, setReason] = useState('UNSPECIFIED') + const [pem, setPem] = useState(null) + const [notice, setNotice] = useState('') + const certificateKey = ['acm-certificate', accountId, resource?.id] + + // The table row is a ListCertificates summary; validation records and users come from DescribeCertificate. + const certificateQuery = useQuery({ + queryKey: certificateKey, + queryFn: ({signal}) => getCloudResource('aws', 'certificates', resource?.id ?? '', signal), + enabled: Boolean(resource) && runtimeReachable, + }) + + useEffect(() => { + setPassphrase('') + setReason('UNSPECIFIED') + setPem(null) + setNotice('') + }, [resource?.id]) + + const refresh = () => Promise.all([ + queryClient.invalidateQueries({queryKey: certificateKey}), + queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'certificates']}), + ]) + + const pemMutation = useMutation({ + mutationFn: () => getCertificatePem('aws', resource?.id ?? ''), + onSuccess: setPem, + }) + const exportMutation = useMutation({ + mutationFn: () => exportCertificate('aws', resource?.id ?? '', passphrase), + onSuccess: (exported) => { + setPem(exported) + setPassphrase('') + setNotice('Exported. The private key is encrypted with your passphrase.') + }, + }) + const revokeMutation = useMutation({ + mutationFn: () => revokeCertificate('aws', resource?.id ?? '', reason), + onSuccess: () => { + setNotice('Certificate revoked. It can no longer be renewed or exported.') + return refresh() + }, + }) + + if (!resource) { + return

Select a certificate

Choose a certificate to see its validation records, where it is used, and to download or export it.

+ } + + const certificate = certificateQuery.data ?? resource + const metadata = certificate.metadata + const validations = list(metadata.domainValidations) + const inUseBy = list(metadata.inUseBy) + const exportable = metadata.exportable === true + const issued = certificate.status === 'ISSUED' + const pending = pemMutation.isPending || exportMutation.isPending || revokeMutation.isPending + const disabled = !runtimeReachable || pending + const error = certificateQuery.error ?? pemMutation.error ?? exportMutation.error ?? revokeMutation.error + const fileStem = certificate.name.replace(/^\*\./, 'wildcard.').replace(/[^a-z0-9.-]/gi, '_') + + return
+
+

Certificate

{certificate.status ?? 'Unknown'} · {String(metadata.certificateType ?? '')}{exportable ? ' · exportable' : ''}{metadata.notAfter ? ` · expires ${formatDateTime(String(metadata.notAfter))}` : ''}
+
+ + +
+
+ + {validations.length > 0 && <> +

Domains

On AWS, each name is validated by the CNAME record below before the certificate is issued. TheCloud issues it straight away.
+
{validations.map((validation) => ( + + + + + + + ))}
DomainValidationCNAME nameCNAME value
{validation.domainName}{validation.status ?? '—'}{validation.method ? ` ${validation.method}` : ''}{validation.record ? {validation.record.name} : '—'}{validation.record ? {validation.record.value} : '—'}
+ } + +

In use by

{inUseBy.length > 0 ? 'ACM will not delete the certificate until these stop using it.' : 'Nothing uses this certificate, so it can be deleted.'}
+ {inUseBy.length > 0 &&
    {inUseBy.map((arn) =>
  • {arn}
  • )}
} + + {exportable && issued && <> +

Export

Download the certificate with its private key, encrypted with a passphrase, to use on a server outside AWS.
+
{ event.preventDefault(); setNotice(''); exportMutation.mutate() }}> + +
+
+ } + + {exportable && certificate.status !== 'REVOKED' && <> +

Revoke

Revoking cannot be undone. Use it if the private key leaked or the certificate is no longer needed.
+
{ + event.preventDefault() + setNotice('') + if (window.confirm(`Revoke the certificate for ${certificate.name}? This cannot be undone.`)) revokeMutation.mutate() + }}> + +
+
+ } + + {pem &&
+ + {pem.certificateChain && } + {'privateKey' in pem && } +
} + + {notice &&

{notice}

} + {error instanceof Error &&

{error.message}

} +
+} + +function PemBlock({label, value, file}: {label: string; value: string; file: string}) { + const href = `data:application/x-pem-file;charset=utf-8,${encodeURIComponent(value)}` + return
+
+ {label} + + {file} +
+