diff --git a/src/ServerWide/Operations/Certificates/CertificateResponseKeyCaseTransform.ts b/src/ServerWide/Operations/Certificates/CertificateResponseKeyCaseTransform.ts new file mode 100644 index 00000000..5a69d80e --- /dev/null +++ b/src/ServerWide/Operations/Certificates/CertificateResponseKeyCaseTransform.ts @@ -0,0 +1,21 @@ +import { ObjectUtil } from "../../../Utility/ObjectUtil.js"; +import { ObjectKeyCaseTransformStreamOptions } from "../../../Mapping/Json/Streams/ObjectKeyCaseTransformStream.js"; + +/** + * Key-case transform for certificate read responses. + * + * The `permissions` map is keyed by database names, which must be read back verbatim: the default + * deep camelCasing would lowercase the first letter of each key (e.g. "UPPER_db" -> "uPPER_db"), + * breaking case-sensitive permission matching on the server (RDBC-1085). We ignore those keys while + * still camelCasing every other response field. Database names may contain '.', so the ignore path + * matches everything under `permissions` (the values are primitive strings, so there is no nesting + * below the key that would need transforming). + * + * Shared by all certificate read operations so their handling of permission keys can't drift. + * The `results.[]` path prefix is coupled to the array-wrapped response shape those operations all + * rely on; an operation returning permissions at a different path would need its own transform. + */ +export const CERTIFICATE_RESPONSE_KEY_CASE_TRANSFORM: ObjectKeyCaseTransformStreamOptions = { + defaultTransform: ObjectUtil.camel, + ignorePaths: [/^results\.\[\]\.permissions\..+$/i] +}; diff --git a/src/ServerWide/Operations/Certificates/CreateClientCertificateOperation.ts b/src/ServerWide/Operations/Certificates/CreateClientCertificateOperation.ts index 48645bfa..670a9132 100644 --- a/src/ServerWide/Operations/Certificates/CreateClientCertificateOperation.ts +++ b/src/ServerWide/Operations/Certificates/CreateClientCertificateOperation.ts @@ -12,6 +12,7 @@ import { RavenCommand } from "../../../Http/RavenCommand.js"; import { ServerNode } from "../../../Http/ServerNode.js"; import { IRaftCommand } from "../../../Http/IRaftCommand.js"; import { RaftIdGenerator } from "../../../Utility/RaftIdGenerator.js"; +import { JsonSerializer } from "../../../Mapping/Json/Serializer.js"; export class CreateClientCertificateOperation implements IServerOperation { private readonly _name: string; @@ -74,13 +75,15 @@ class CreateClientCertificateCommand extends RavenCommand im createRequest(node: ServerNode): HttpRequestParameters { const uri = node.url + "/admin/certificates"; - const body = this._serializer + // Field names are already PascalCased here, so use the casing-preserving serializer: + // the default command-payload serializer would also PascalCase the first letter of every + // `permissions` key (database names), breaking case-sensitive permission matching (RDBC-1085). + const body = JsonSerializer.getDefault() .serialize({ Name: this._name, SecurityClearance: this._clearance, Password: this._password || undefined, - Permissions: this._permissions, - + Permissions: this._permissions }); return { diff --git a/src/ServerWide/Operations/Certificates/EditClientCertificateOperation.ts b/src/ServerWide/Operations/Certificates/EditClientCertificateOperation.ts index 5e0ff9d0..d9ce15d8 100644 --- a/src/ServerWide/Operations/Certificates/EditClientCertificateOperation.ts +++ b/src/ServerWide/Operations/Certificates/EditClientCertificateOperation.ts @@ -8,7 +8,7 @@ import { DocumentConventions } from "../../../Documents/Conventions/DocumentConv import { RavenCommand } from "../../../Http/RavenCommand.js"; import { IRaftCommand } from "../../../Http/IRaftCommand.js"; import { ServerNode } from "../../../Http/ServerNode.js"; -import { CertificateDefinition } from "./CertificateDefinition.js"; +import { JsonSerializer } from "../../../Mapping/Json/Serializer.js"; export class EditClientCertificateOperation implements IServerOperation { private readonly _thumbprint: string; @@ -74,14 +74,18 @@ class EditClientCertificateCommand extends RavenCommand implements IRaftCo createRequest(node: ServerNode): HttpRequestParameters { const uri = node.url + "/admin/certificates/edit"; + // Field names are PascalCased explicitly here (rather than via the default command-payload + // serializer) so the casing-preserving serializer can be used: the default one would also + // PascalCase the first letter of every `permissions` key (database names), breaking + // case-sensitive permission matching (RDBC-1085). const definition = { - thumbprint: this._thumbprint, - permissions: this._permissions, - securityClearance: this._clearance, - name: this._name - } as CertificateDefinition; + Thumbprint: this._thumbprint, + Permissions: this._permissions, + SecurityClearance: this._clearance, + Name: this._name + }; - const body = this._serializer.serialize(definition); + const body = JsonSerializer.getDefault().serialize(definition); return { method: "POST", diff --git a/src/ServerWide/Operations/Certificates/GetCertificateMetadataOperation.ts b/src/ServerWide/Operations/Certificates/GetCertificateMetadataOperation.ts index 890938c1..725346d3 100644 --- a/src/ServerWide/Operations/Certificates/GetCertificateMetadataOperation.ts +++ b/src/ServerWide/Operations/Certificates/GetCertificateMetadataOperation.ts @@ -8,6 +8,7 @@ import { RavenCommand } from "../../../Http/RavenCommand.js"; import { ServerNode } from "../../../Http/ServerNode.js"; import { ServerResponse } from "../../../Types/index.js"; import { DateUtil } from "../../../Utility/DateUtil.js"; +import { CERTIFICATE_RESPONSE_KEY_CASE_TRANSFORM } from "./CertificateResponseKeyCaseTransform.js"; export class GetCertificateMetadataOperation implements IServerOperation { private readonly _thumbprint: string; @@ -61,7 +62,9 @@ class GetCertificateMetadataCommand extends RavenCommand { } let body: string = null; - const response = await this._defaultPipeline>(_ => body = _).process(bodyStream); + const response = await this._defaultPipeline>(_ => body = _) + .objectKeysTransform(CERTIFICATE_RESPONSE_KEY_CASE_TRANSFORM) + .process(bodyStream); const resultsMapped: CertificateMetadata[] = response.results.map(cert => { const { notAfter, notBefore } = cert; diff --git a/src/ServerWide/Operations/Certificates/GetCertificateOperation.ts b/src/ServerWide/Operations/Certificates/GetCertificateOperation.ts index ae888cb9..bbf6e233 100644 --- a/src/ServerWide/Operations/Certificates/GetCertificateOperation.ts +++ b/src/ServerWide/Operations/Certificates/GetCertificateOperation.ts @@ -6,6 +6,7 @@ import { IServerOperation, OperationResultType } from "../../../Documents/Operat import { DocumentConventions } from "../../../Documents/Conventions/DocumentConventions.js"; import { RavenCommand } from "../../../Http/RavenCommand.js"; import { ServerNode } from "../../../Http/ServerNode.js"; +import { CERTIFICATE_RESPONSE_KEY_CASE_TRANSFORM } from "./CertificateResponseKeyCaseTransform.js"; export class GetCertificateOperation implements IServerOperation { private readonly _thumbprint: string; @@ -60,7 +61,9 @@ class GetCertificateCommand extends RavenCommand { } let body: string = null; - const results = await this._defaultPipeline(_ => body = _).process(bodyStream); + const results = await this._defaultPipeline(_ => body = _) + .objectKeysTransform(CERTIFICATE_RESPONSE_KEY_CASE_TRANSFORM) + .process(bodyStream); const mapped = this._conventions.objectMapper.fromObjectLiteral<{ results: CertificateDefinition[] }>(results, { nestedTypes: { "results[].notAfter": "date", diff --git a/src/ServerWide/Operations/Certificates/GetCertificatesMetadataOperation.ts b/src/ServerWide/Operations/Certificates/GetCertificatesMetadataOperation.ts index aad57435..fb3a8851 100644 --- a/src/ServerWide/Operations/Certificates/GetCertificatesMetadataOperation.ts +++ b/src/ServerWide/Operations/Certificates/GetCertificatesMetadataOperation.ts @@ -5,6 +5,7 @@ import { IServerOperation, OperationResultType } from "../../../Documents/Operat import { DocumentConventions } from "../../../Documents/Conventions/DocumentConventions.js"; import { RavenCommand } from "../../../Http/RavenCommand.js"; import { ServerNode } from "../../../Http/ServerNode.js"; +import { CERTIFICATE_RESPONSE_KEY_CASE_TRANSFORM } from "./CertificateResponseKeyCaseTransform.js"; import { Stream } from "node:stream"; export class GetCertificatesMetadataOperation implements IServerOperation { @@ -59,7 +60,9 @@ class GetCertificatesMetadataCommand extends RavenCommand } let body: string = null; - const results = await this._defaultPipeline(_ => body = _).process(bodyStream); + const results = await this._defaultPipeline<{ results: CertificateMetadata[] }>(_ => body = _) + .objectKeysTransform(CERTIFICATE_RESPONSE_KEY_CASE_TRANSFORM) + .process(bodyStream); this.result = this._conventions.objectMapper.fromObjectLiteral<{ results: CertificateMetadata[] }>(results, { nestedTypes: { "results[].notAfter": "date", diff --git a/src/ServerWide/Operations/Certificates/GetCertificatesOperation.ts b/src/ServerWide/Operations/Certificates/GetCertificatesOperation.ts index de665768..1a57f603 100644 --- a/src/ServerWide/Operations/Certificates/GetCertificatesOperation.ts +++ b/src/ServerWide/Operations/Certificates/GetCertificatesOperation.ts @@ -5,6 +5,7 @@ import { IServerOperation, OperationResultType } from "../../../Documents/Operat import { RavenCommand } from "../../../Http/RavenCommand.js"; import { DocumentConventions } from "../../../Documents/Conventions/DocumentConventions.js"; import { ServerNode } from "../../../Http/ServerNode.js"; +import { CERTIFICATE_RESPONSE_KEY_CASE_TRANSFORM } from "./CertificateResponseKeyCaseTransform.js"; export class GetCertificatesOperation implements IServerOperation { @@ -57,7 +58,9 @@ class GetCertificatesCommand extends RavenCommand { } let body: string = null; - const results = await this._defaultPipeline(_ => body = _).process(bodyStream); + const results = await this._defaultPipeline(_ => body = _) + .objectKeysTransform(CERTIFICATE_RESPONSE_KEY_CASE_TRANSFORM) + .process(bodyStream); this.result = this._conventions.objectMapper.fromObjectLiteral<{ results: CertificateDefinition[] }>(results, { nestedTypes: { "results[].notAfter": "date", diff --git a/src/ServerWide/Operations/Certificates/PutClientCertificateOperation.ts b/src/ServerWide/Operations/Certificates/PutClientCertificateOperation.ts index 3e3ec3ff..91efebae 100644 --- a/src/ServerWide/Operations/Certificates/PutClientCertificateOperation.ts +++ b/src/ServerWide/Operations/Certificates/PutClientCertificateOperation.ts @@ -9,6 +9,7 @@ import { RavenCommand } from "../../../Http/RavenCommand.js"; import { ServerNode } from "../../../Http/ServerNode.js"; import { IRaftCommand } from "../../../Http/IRaftCommand.js"; import { RaftIdGenerator } from "../../../Utility/RaftIdGenerator.js"; +import { JsonSerializer } from "../../../Mapping/Json/Serializer.js"; export class PutClientCertificateOperation implements IServerOperation { private readonly _certificate: string; @@ -82,7 +83,10 @@ class PutClientCertificateCommand extends RavenCommand implements IRaftCom createRequest(node: ServerNode): HttpRequestParameters { const uri = node.url + "/admin/certificates"; - const body = this._serializer + // Field names are already PascalCased here, so use the casing-preserving serializer: + // the default command-payload serializer would also PascalCase the first letter of every + // `permissions` key (database names), breaking case-sensitive permission matching (RDBC-1085). + const body = JsonSerializer.getDefault() .serialize({ Name: this._name, Certificate: this._certificate, diff --git a/test/Issues/RDBC-1085.ts b/test/Issues/RDBC-1085.ts new file mode 100644 index 00000000..d91b9401 --- /dev/null +++ b/test/Issues/RDBC-1085.ts @@ -0,0 +1,226 @@ +import assert from "node:assert"; +import { Readable } from "node:stream"; +import { + CreateClientCertificateOperation, + PutClientCertificateOperation, + EditClientCertificateOperation, + CreateDatabaseOperation, + DeleteDatabasesOperation, + DeleteCertificateOperation, + GetCertificateOperation, + GetCertificateMetadataOperation, + GetCertificatesOperation, + GetCertificatesMetadataOperation, + GetDatabaseNamesOperation, + DatabaseAccess, + DocumentConventions, + IDocumentStore +} from "../../src/index.js"; +import { disposeTestDocumentStore, testContext } from "../Utils/TestUtil.js"; + +// RDBC-1085: the client's command-payload serializer PascalCases the first letter of every object +// key, including the keys of the `permissions` map (which are database names). A lowercase database +// name like "lowercase_db" was being sent as "Lowercase_db", and because the server matches permission +// keys case-sensitively, certificate permissions no longer lined up with the database name. +// The permission keys must travel to the server verbatim. +describe("[RDBC-1085]", () => { + + const conventions = new DocumentConventions(); + const node = { url: "http://localhost:8080", database: "test" } as any; + + // "My.Db" also guards the write path against mangling database names that contain '.'. + const permissions = { + "lowercase_db": "Admin", + "mixedCaseDb": "ReadWrite", + "UPPER_db": "Read", + "My.Db": "Admin" + } as Record; + const expectedWrittenKeys = ["lowercase_db", "mixedCaseDb", "UPPER_db", "My.Db"]; + + function permissionKeysOf(body: string): string[] { + return Object.keys(JSON.parse(body).Permissions ?? {}); + } + + it("CreateClientCertificateOperation keeps permission (database-name) keys verbatim", () => { + const command = new CreateClientCertificateOperation("cert", permissions, "ValidUser") + .getCommand(conventions); + const body = command.createRequest(node).body as string; + + assert.deepStrictEqual(permissionKeysOf(body), expectedWrittenKeys); + }); + + it("PutClientCertificateOperation keeps permission (database-name) keys verbatim", () => { + const command = new PutClientCertificateOperation("cert", "public-key", permissions, "ValidUser") + .getCommand(conventions); + const body = command.createRequest(node).body as string; + + assert.deepStrictEqual(permissionKeysOf(body), expectedWrittenKeys); + }); + + it("EditClientCertificateOperation keeps permission (database-name) keys verbatim", () => { + const command = new EditClientCertificateOperation({ + thumbprint: "ABC123", + name: "cert", + clearance: "ValidUser", + permissions + }).getCommand(conventions); + const body = command.createRequest(node).body as string; + + assert.deepStrictEqual(permissionKeysOf(body), expectedWrittenKeys); + }); + + it("top-level certificate fields are still PascalCased on the wire", () => { + const command = new CreateClientCertificateOperation("cert", permissions, "ValidUser") + .getCommand(conventions); + const parsed = JSON.parse(command.createRequest(node).body as string); + + assert.ok("Name" in parsed, "expected PascalCased 'Name' field"); + assert.ok("SecurityClearance" in parsed, "expected PascalCased 'SecurityClearance' field"); + assert.ok("Permissions" in parsed, "expected PascalCased 'Permissions' field"); + }); + + // A "My.Db" key also guards the ignore-path regex: database names may contain '.'. + const serverPermissions = { + "lowercase_db": "Admin", + "mixedCaseDb": "ReadWrite", + "UPPER_db": "Read", + "My.Db": "Admin" + }; + const expectedPermissionKeys = ["lowercase_db", "mixedCaseDb", "UPPER_db", "My.Db"]; + + function singleCertResponse(): string { + return JSON.stringify({ + Results: [{ + Name: "cert", + SecurityClearance: "ValidUser", + Thumbprint: "ABC123", + NotAfter: "2099-01-01T00:00:00.0000000Z", + NotBefore: "2020-01-01T00:00:00.0000000Z", + Permissions: serverPermissions + }] + }); + } + + it("GetCertificatesMetadataOperation reads permission (database-name) keys verbatim", async () => { + const command = new GetCertificatesMetadataOperation("cert").getCommand(conventions); + await command.setResponseAsync(Readable.from([singleCertResponse()]), false); + + assert.deepStrictEqual(Object.keys(command.result[0].permissions ?? {}), expectedPermissionKeys); + }); + + it("GetCertificateMetadataOperation reads permission (database-name) keys verbatim", async () => { + const command = new GetCertificateMetadataOperation("ABC123").getCommand(conventions); + await command.setResponseAsync(Readable.from([singleCertResponse()]), false); + + assert.deepStrictEqual(Object.keys(command.result.permissions ?? {}), expectedPermissionKeys); + }); + + it("GetCertificateOperation reads permission (database-name) keys verbatim", async () => { + const command = new GetCertificateOperation("ABC123").getCommand(conventions); + await command.setResponseAsync(Readable.from([singleCertResponse()]), false); + + assert.deepStrictEqual(Object.keys(command.result.permissions ?? {}), expectedPermissionKeys); + }); + + it("GetCertificatesOperation reads permission (database-name) keys verbatim", async () => { + const command = new GetCertificatesOperation(0, 20).getCommand(conventions); + await command.setResponseAsync(Readable.from([singleCertResponse()]), false); + + assert.deepStrictEqual(Object.keys(command.result[0].permissions ?? {}), expectedPermissionKeys); + }); +}); + +// Full end-to-end reproduction of the ticket flow against a secured server. Certificate operations +// require HTTPS + a client certificate, so this uses a secured document store. +describe("[RDBC-1085] full flow", function () { + + let store: IDocumentStore; + + beforeEach(async function () { + store = await testContext.getSecuredDocumentStore(); + }); + + afterEach(async () => + await disposeTestDocumentStore(store)); + + it("preserves database-name casing across delete/recreate and keeps certificate permissions aligned", async function () { + this.timeout(60_000); + + // A mixed-case name exercises both the write path (permission key sent verbatim) and the + // read path (permission key read back verbatim). A lowercase-first name would mask the read + // path, since camelCasing a name that already starts lowercase is a no-op. + const requestedName = "MyDb"; + const certName = "RDBC-1085-full-flow-cert"; + + let thumbprint: string; + + try { + // Register a client certificate whose permissions are keyed to the requested database name. + await store.maintenance.server.send(new CreateClientCertificateOperation( + certName, + { [requestedName]: "Admin" } as Record, + "ValidUser")); + + // Create the database with the requested name. + await store.maintenance.server.send( + new CreateDatabaseOperation({ databaseName: requestedName })); + + // 1. Capture the target database's certificate permissions. + const metadataBefore = await store.maintenance.server.send( + new GetCertificatesMetadataOperation(certName)); + assert.strictEqual(metadataBefore.length, 1); + thumbprint = metadataBefore[0].thumbprint; + const capturedPermissions = metadataBefore[0].permissions ?? {}; + + // The captured permission key must match the requested database name. + assert.deepStrictEqual(Object.keys(capturedPermissions), [requestedName]); + + // 2. Hard-delete the target database. + await store.maintenance.server.send(new DeleteDatabasesOperation({ + databaseNames: [requestedName], + hardDelete: true + })); + + // 3. Recreate it using the same name. + await store.maintenance.server.send( + new CreateDatabaseOperation({ databaseName: requestedName })); + + // 4. Restore the captured certificate permissions. + await store.maintenance.server.send(new EditClientCertificateOperation({ + thumbprint, + name: metadataBefore[0].name, + permissions: capturedPermissions, + clearance: metadataBefore[0].securityClearance + })); + + // The recreated database must keep the exact casing we requested. + const databaseNames = await store.maintenance.server.send( + new GetDatabaseNamesOperation(0, 100)); + assert.ok(databaseNames.includes(requestedName), + `expected database '${requestedName}' but got ${JSON.stringify(databaseNames)}`); + + // The restored permission key must still line up (case-sensitively) with the database name. + const metadataAfter = await store.maintenance.server.send( + new GetCertificatesMetadataOperation(certName)); + assert.strictEqual(metadataAfter.length, 1); + assert.deepStrictEqual(Object.keys(metadataAfter[0].permissions ?? {}), [requestedName]); + } finally { + // Clean up the recreated database and the test certificate. + try { + await store.maintenance.server.send(new DeleteDatabasesOperation({ + databaseNames: [requestedName], + hardDelete: true + })); + } catch { + // ignore + } + if (thumbprint) { + try { + await store.maintenance.server.send(new DeleteCertificateOperation(thumbprint)); + } catch { + // ignore + } + } + } + }); +});