Skip to content

Commit 3a5f55e

Browse files
committed
RDBC-1085 Keep certificate permission (database-name) keys verbatim
The client mangled the casing of certificate permission keys, which are database names, on both the write and read paths: - Write: the command-payload serializer PascalCases the first letter of every object key, so a permission map like {"myDb": ...} was sent as {"MyDb": ...}. - Read: GetCertificatesMetadataOperation deep-camelCases every response key, so {"UPPER_db": ...} was read back as {"uPPER_db": ...}. Because the server matches permission keys case-sensitively, either transform broke the alignment between a certificate's permissions and the database name. Fixes both directions, mirroring the C# client / the existing GetDatabaseRecordOperation pattern for arbitrary-keyed maps: - Create/Put/Edit client certificate payloads are serialized with the casing-preserving serializer and already-PascalCased field names. - GetCertificatesMetadataOperation ignores the `permissions` map keys when camelCasing the response.
1 parent 12df664 commit 3a5f55e

5 files changed

Lines changed: 223 additions & 10 deletions

File tree

src/ServerWide/Operations/Certificates/CreateClientCertificateOperation.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { RavenCommand } from "../../../Http/RavenCommand.js";
1212
import { ServerNode } from "../../../Http/ServerNode.js";
1313
import { IRaftCommand } from "../../../Http/IRaftCommand.js";
1414
import { RaftIdGenerator } from "../../../Utility/RaftIdGenerator.js";
15+
import { JsonSerializer } from "../../../Mapping/Json/Serializer.js";
1516

1617
export class CreateClientCertificateOperation implements IServerOperation<CertificateRawData> {
1718
private readonly _name: string;
@@ -74,7 +75,10 @@ class CreateClientCertificateCommand extends RavenCommand<CertificateRawData> im
7475
createRequest(node: ServerNode): HttpRequestParameters {
7576
const uri = node.url + "/admin/certificates";
7677

77-
const body = this._serializer
78+
// Field names are already PascalCased here, so use the casing-preserving serializer:
79+
// the default command-payload serializer would also PascalCase the first letter of every
80+
// `permissions` key (database names), breaking case-sensitive permission matching (RDBC-1085).
81+
const body = JsonSerializer.getDefault()
7882
.serialize({
7983
Name: this._name,
8084
SecurityClearance: this._clearance,

src/ServerWide/Operations/Certificates/EditClientCertificateOperation.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { DocumentConventions } from "../../../Documents/Conventions/DocumentConv
88
import { RavenCommand } from "../../../Http/RavenCommand.js";
99
import { IRaftCommand } from "../../../Http/IRaftCommand.js";
1010
import { ServerNode } from "../../../Http/ServerNode.js";
11-
import { CertificateDefinition } from "./CertificateDefinition.js";
11+
import { JsonSerializer } from "../../../Mapping/Json/Serializer.js";
1212

1313
export class EditClientCertificateOperation implements IServerOperation<void> {
1414
private readonly _thumbprint: string;
@@ -74,14 +74,18 @@ class EditClientCertificateCommand extends RavenCommand<void> implements IRaftCo
7474
createRequest(node: ServerNode): HttpRequestParameters {
7575
const uri = node.url + "/admin/certificates/edit";
7676

77+
// Field names are PascalCased explicitly here (rather than via the default command-payload
78+
// serializer) so the casing-preserving serializer can be used: the default one would also
79+
// PascalCase the first letter of every `permissions` key (database names), breaking
80+
// case-sensitive permission matching (RDBC-1085).
7781
const definition = {
78-
thumbprint: this._thumbprint,
79-
permissions: this._permissions,
80-
securityClearance: this._clearance,
81-
name: this._name
82-
} as CertificateDefinition;
82+
Thumbprint: this._thumbprint,
83+
Permissions: this._permissions,
84+
SecurityClearance: this._clearance,
85+
Name: this._name
86+
};
8387

84-
const body = this._serializer.serialize(definition);
88+
const body = JsonSerializer.getDefault().serialize(definition);
8589

8690
return {
8791
method: "POST",

src/ServerWide/Operations/Certificates/GetCertificatesMetadataOperation.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { IServerOperation, OperationResultType } from "../../../Documents/Operat
55
import { DocumentConventions } from "../../../Documents/Conventions/DocumentConventions.js";
66
import { RavenCommand } from "../../../Http/RavenCommand.js";
77
import { ServerNode } from "../../../Http/ServerNode.js";
8+
import { ObjectUtil } from "../../../Utility/ObjectUtil.js";
89
import { Stream } from "node:stream";
910

1011
export class GetCertificatesMetadataOperation implements IServerOperation<CertificateMetadata[]> {
@@ -59,7 +60,15 @@ class GetCertificatesMetadataCommand extends RavenCommand<CertificateMetadata[]>
5960
}
6061

6162
let body: string = null;
62-
const results = await this._defaultPipeline(_ => body = _).process(bodyStream);
63+
// The `permissions` map is keyed by database names, which must be read back verbatim: the
64+
// default deep camelCasing would lowercase the first letter of each key (e.g. "UPPER_db" ->
65+
// "uPPER_db"), breaking case-sensitive permission matching (RDBC-1085). Ignore those keys.
66+
const results = await this._defaultPipeline<{ results: CertificateMetadata[] }>(_ => body = _)
67+
.objectKeysTransform({
68+
defaultTransform: ObjectUtil.camel,
69+
ignorePaths: [/^results\.\[\]\.permissions\.[^.]+$/i]
70+
})
71+
.process(bodyStream);
6372
this.result = this._conventions.objectMapper.fromObjectLiteral<{ results: CertificateMetadata[] }>(results, {
6473
nestedTypes: {
6574
"results[].notAfter": "date",

src/ServerWide/Operations/Certificates/PutClientCertificateOperation.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { RavenCommand } from "../../../Http/RavenCommand.js";
99
import { ServerNode } from "../../../Http/ServerNode.js";
1010
import { IRaftCommand } from "../../../Http/IRaftCommand.js";
1111
import { RaftIdGenerator } from "../../../Utility/RaftIdGenerator.js";
12+
import { JsonSerializer } from "../../../Mapping/Json/Serializer.js";
1213

1314
export class PutClientCertificateOperation implements IServerOperation<void> {
1415
private readonly _certificate: string;
@@ -82,7 +83,10 @@ class PutClientCertificateCommand extends RavenCommand<void> implements IRaftCom
8283
createRequest(node: ServerNode): HttpRequestParameters {
8384
const uri = node.url + "/admin/certificates";
8485

85-
const body = this._serializer
86+
// Field names are already PascalCased here, so use the casing-preserving serializer:
87+
// the default command-payload serializer would also PascalCase the first letter of every
88+
// `permissions` key (database names), breaking case-sensitive permission matching (RDBC-1085).
89+
const body = JsonSerializer.getDefault()
8690
.serialize({
8791
Name: this._name,
8892
Certificate: this._certificate,

test/Issues/RDBC-1085.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import assert from "node:assert";
2+
import { Readable } from "node:stream";
3+
import {
4+
CreateClientCertificateOperation,
5+
PutClientCertificateOperation,
6+
EditClientCertificateOperation,
7+
CreateDatabaseOperation,
8+
DeleteDatabasesOperation,
9+
DeleteCertificateOperation,
10+
GetCertificatesMetadataOperation,
11+
GetDatabaseNamesOperation,
12+
DatabaseAccess,
13+
DocumentConventions,
14+
IDocumentStore
15+
} from "../../src/index.js";
16+
import { disposeTestDocumentStore, testContext } from "../Utils/TestUtil.js";
17+
18+
// RDBC-1085: the client's command-payload serializer PascalCases the first letter of every object
19+
// key, including the keys of the `permissions` map (which are database names). A lowercase database
20+
// name like "lowercase_db" was being sent as "Lowercase_db", and because the server matches permission
21+
// keys case-sensitively, certificate permissions no longer lined up with the database name.
22+
// The permission keys must travel to the server verbatim.
23+
describe("[RDBC-1085]", () => {
24+
25+
const conventions = new DocumentConventions();
26+
const node = { url: "http://localhost:8080", database: "test" } as any;
27+
28+
const permissions = {
29+
"lowercase_db": "Admin",
30+
"mixedCaseDb": "ReadWrite",
31+
"UPPER_db": "Read"
32+
} as Record<string, DatabaseAccess>;
33+
34+
function permissionKeysOf(body: string): string[] {
35+
return Object.keys(JSON.parse(body).Permissions ?? {});
36+
}
37+
38+
it("CreateClientCertificateOperation keeps permission (database-name) keys verbatim", () => {
39+
const command = new CreateClientCertificateOperation("cert", permissions, "ValidUser")
40+
.getCommand(conventions);
41+
const body = command.createRequest(node).body as string;
42+
43+
assert.deepStrictEqual(permissionKeysOf(body), ["lowercase_db", "mixedCaseDb", "UPPER_db"]);
44+
});
45+
46+
it("PutClientCertificateOperation keeps permission (database-name) keys verbatim", () => {
47+
const command = new PutClientCertificateOperation("cert", "public-key", permissions, "ValidUser")
48+
.getCommand(conventions);
49+
const body = command.createRequest(node).body as string;
50+
51+
assert.deepStrictEqual(permissionKeysOf(body), ["lowercase_db", "mixedCaseDb", "UPPER_db"]);
52+
});
53+
54+
it("EditClientCertificateOperation keeps permission (database-name) keys verbatim", () => {
55+
const command = new EditClientCertificateOperation({
56+
thumbprint: "ABC123",
57+
name: "cert",
58+
clearance: "ValidUser",
59+
permissions
60+
}).getCommand(conventions);
61+
const body = command.createRequest(node).body as string;
62+
63+
assert.deepStrictEqual(permissionKeysOf(body), ["lowercase_db", "mixedCaseDb", "UPPER_db"]);
64+
});
65+
66+
it("top-level certificate fields are still PascalCased on the wire", () => {
67+
const command = new CreateClientCertificateOperation("cert", permissions, "ValidUser")
68+
.getCommand(conventions);
69+
const parsed = JSON.parse(command.createRequest(node).body as string);
70+
71+
assert.ok("Name" in parsed, "expected PascalCased 'Name' field");
72+
assert.ok("SecurityClearance" in parsed, "expected PascalCased 'SecurityClearance' field");
73+
assert.ok("Permissions" in parsed, "expected PascalCased 'Permissions' field");
74+
});
75+
76+
it("GetCertificatesMetadataOperation reads permission (database-name) keys verbatim", async () => {
77+
// Server response shape, with permission keys of assorted casing.
78+
const serverResponse = JSON.stringify({
79+
Results: [{
80+
Name: "cert",
81+
SecurityClearance: "ValidUser",
82+
Thumbprint: "ABC123",
83+
Permissions: {
84+
"lowercase_db": "Admin",
85+
"mixedCaseDb": "ReadWrite",
86+
"UPPER_db": "Read"
87+
}
88+
}]
89+
});
90+
91+
const command = new GetCertificatesMetadataOperation("cert").getCommand(conventions);
92+
await command.setResponseAsync(Readable.from([serverResponse]), false);
93+
94+
const keys = Object.keys(command.result[0].permissions ?? {});
95+
assert.deepStrictEqual(keys, ["lowercase_db", "mixedCaseDb", "UPPER_db"]);
96+
});
97+
});
98+
99+
// Full end-to-end reproduction of the ticket flow against a secured server. Certificate operations
100+
// require HTTPS + a client certificate, so this uses a secured document store.
101+
describe("[RDBC-1085] full flow", function () {
102+
103+
let store: IDocumentStore;
104+
105+
beforeEach(async function () {
106+
store = await testContext.getSecuredDocumentStore();
107+
});
108+
109+
afterEach(async () =>
110+
await disposeTestDocumentStore(store));
111+
112+
it("preserves lowercase database-name casing across delete/recreate and keeps certificate permissions aligned", async function () {
113+
this.timeout(60_000);
114+
115+
// A mixed-case name exercises both the write path (permission key sent verbatim) and the
116+
// read path (permission key read back verbatim). A lowercase-first name would mask the read
117+
// path, since camelCasing a name that already starts lowercase is a no-op.
118+
const requestedName = "MyDb";
119+
const certName = "RDBC-1085-full-flow-cert";
120+
121+
let thumbprint: string;
122+
123+
try {
124+
// Register a client certificate whose permissions are keyed to the lowercase database name.
125+
await store.maintenance.server.send(new CreateClientCertificateOperation(
126+
certName,
127+
{ [requestedName]: "Admin" } as Record<string, DatabaseAccess>,
128+
"ValidUser"));
129+
130+
// Create the database with the lowercase name.
131+
await store.maintenance.server.send(
132+
new CreateDatabaseOperation({ databaseName: requestedName }));
133+
134+
// 1. Capture the target database's certificate permissions.
135+
const metadataBefore = await store.maintenance.server.send(
136+
new GetCertificatesMetadataOperation(certName));
137+
assert.strictEqual(metadataBefore.length, 1);
138+
thumbprint = metadataBefore[0].thumbprint;
139+
const capturedPermissions = metadataBefore[0].permissions ?? {};
140+
141+
// The captured permission key must match the requested (lowercase) database name.
142+
assert.deepStrictEqual(Object.keys(capturedPermissions), [requestedName]);
143+
144+
// 2. Hard-delete the target database.
145+
await store.maintenance.server.send(new DeleteDatabasesOperation({
146+
databaseNames: [requestedName],
147+
hardDelete: true
148+
}));
149+
150+
// 3. Recreate it using the same lowercase name.
151+
await store.maintenance.server.send(
152+
new CreateDatabaseOperation({ databaseName: requestedName }));
153+
154+
// 4. Restore the captured certificate permissions.
155+
await store.maintenance.server.send(new EditClientCertificateOperation({
156+
thumbprint,
157+
name: metadataBefore[0].name,
158+
permissions: capturedPermissions,
159+
clearance: metadataBefore[0].securityClearance
160+
}));
161+
162+
// The recreated database must keep the exact casing we requested.
163+
const databaseNames = await store.maintenance.server.send(
164+
new GetDatabaseNamesOperation(0, 100));
165+
assert.ok(databaseNames.includes(requestedName),
166+
`expected database '${requestedName}' but got ${JSON.stringify(databaseNames)}`);
167+
168+
// The restored permission key must still line up (case-sensitively) with the database name.
169+
const metadataAfter = await store.maintenance.server.send(
170+
new GetCertificatesMetadataOperation(certName));
171+
assert.strictEqual(metadataAfter.length, 1);
172+
assert.deepStrictEqual(Object.keys(metadataAfter[0].permissions ?? {}), [requestedName]);
173+
} finally {
174+
// Clean up the recreated database and the test certificate.
175+
try {
176+
await store.maintenance.server.send(new DeleteDatabasesOperation({
177+
databaseNames: [requestedName],
178+
hardDelete: true
179+
}));
180+
} catch {
181+
// ignore
182+
}
183+
if (thumbprint) {
184+
try {
185+
await store.maintenance.server.send(new DeleteCertificateOperation(thumbprint));
186+
} catch {
187+
// ignore
188+
}
189+
}
190+
}
191+
});
192+
});

0 commit comments

Comments
 (0)