nodedb 0.3.0

Local-first, real-time, edge-to-cloud hybrid database for multi-modal workloads
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
// SPDX-License-Identifier: BUSL-1.1

//! GRANT / REVOKE role over the pgwire DDL path, plus the readonly guard
//! that covers the same surface.

mod common;

use common::pgwire_auth_helpers::{
    assert_readonly_denied, ddl_err, ddl_ok, make_state, make_state_with_catalog, superuser,
};
use nodedb::control::security::audit::AuditEvent;
use nodedb::control::security::identity::{AuthMethod, AuthenticatedIdentity, Permission, Role};
use nodedb::types::TenantId;

#[tokio::test]
async fn grant_role() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER grace WITH PASSWORD 'pass' ROLE readonly",
    )
    .await;
    ddl_ok(&state, &su, "GRANT ROLE readwrite TO grace").await;

    let user = state.credentials.get_user("grace").unwrap();
    assert!(user.roles.contains(&Role::ReadOnly));
    assert!(user.roles.contains(&Role::ReadWrite));
}

#[tokio::test]
async fn revoke_role() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER heidi WITH PASSWORD 'pass' ROLE readwrite",
    )
    .await;
    ddl_ok(&state, &su, "REVOKE ROLE readwrite FROM heidi").await;

    let user = state.credentials.get_user("heidi").unwrap();
    assert!(!user.roles.contains(&Role::ReadWrite));
}

#[tokio::test]
async fn grant_superuser_requires_superuser() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER ivan WITH PASSWORD 'pass'").await;

    let admin = AuthenticatedIdentity {
        user_id: 50,
        username: "ta".into(),
        tenant_id: TenantId::new(1),
        auth_method: AuthMethod::Trust,
        roles: vec![Role::TenantAdmin],
        is_superuser: false,
        default_database: None,
        accessible_databases: AuthenticatedIdentity::default_database_set(false),
    };
    let err = ddl_err(&state, &admin, "GRANT ROLE superuser TO ivan").await;
    assert!(err.contains("only superuser"), "{err}");
}

#[tokio::test]
async fn revoke_own_superuser_rejected() {
    let state = make_state();
    let su = superuser();
    let err = ddl_err(&state, &su, "REVOKE ROLE superuser FROM nodedb").await;
    assert!(err.contains("cannot revoke your own superuser"), "{err}");
}

#[tokio::test]
async fn readonly_cannot_grant() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER target WITH PASSWORD 'pass'").await;

    assert_readonly_denied(&state, "GRANT ROLE superuser TO target").await;
}

// ── Role-membership grants without the `ROLE` keyword ────────────────
//
// SQL-standard role membership is `GRANT <role> TO <grantee>` — no
// `ROLE` keyword. The disambiguator from an object-permission grant is
// the absence of the `ON` clause, not the presence of `ROLE`. A `GRANT`
// with no `ON` clause is a role grant; with an `ON` clause it is an
// object-permission grant.

#[tokio::test]
async fn grant_builtin_role_without_role_keyword() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER eman WITH PASSWORD 'pass' ROLE readwrite",
    )
    .await;

    // Standard syntax — no `ROLE` keyword, no `ON` clause.
    ddl_ok(&state, &su, "GRANT tenant_admin TO eman").await;

    let user = state.credentials.get_user("eman").unwrap();
    assert!(
        user.roles.contains(&Role::TenantAdmin),
        "GRANT <role> TO <user> must add the role; roles = {:?}",
        user.roles
    );
}

#[tokio::test]
async fn grant_custom_role_without_role_keyword() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE ROLE mae8_admin").await;
    ddl_ok(&state, &su, "CREATE USER xyfer WITH PASSWORD 'pass'").await;

    ddl_ok(&state, &su, "GRANT mae8_admin TO xyfer").await;

    let user = state.credentials.get_user("xyfer").unwrap();
    assert!(
        user.roles.contains(&Role::Custom("mae8_admin".into())),
        "GRANT <custom_role> TO <user> must add the custom role; roles = {:?}",
        user.roles
    );
}

#[tokio::test]
async fn revoke_builtin_role_without_role_keyword() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER demoter WITH PASSWORD 'pass' ROLE tenant_admin",
    )
    .await;

    ddl_ok(&state, &su, "REVOKE tenant_admin FROM demoter").await;

    let user = state.credentials.get_user("demoter").unwrap();
    assert!(
        !user.roles.contains(&Role::TenantAdmin),
        "REVOKE <role> FROM <user> must remove the role; roles = {:?}",
        user.roles
    );
}

#[tokio::test]
async fn revoke_custom_role_without_role_keyword() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE ROLE mae8_reader").await;
    ddl_ok(
        &state,
        &su,
        "CREATE USER inspector WITH PASSWORD 'pass' ROLE mae8_reader",
    )
    .await;

    ddl_ok(&state, &su, "REVOKE mae8_reader FROM inspector").await;

    let user = state.credentials.get_user("inspector").unwrap();
    assert!(
        !user.roles.contains(&Role::Custom("mae8_reader".into())),
        "REVOKE <custom_role> FROM <user> must remove the custom role; roles = {:?}",
        user.roles
    );
}

#[tokio::test]
async fn grant_role_name_aliasing_permission_does_not_misroute() {
    // `monitor` is both a built-in role and a permission alias. A `GRANT
    // monitor TO eman` with no `ON` clause is a role grant — it must NOT
    // silently succeed as an object-permission grant on a phantom
    // collection named after the grantee.
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER eman WITH PASSWORD 'pass'").await;

    ddl_ok(&state, &su, "GRANT monitor TO eman").await;

    let user = state.credentials.get_user("eman").unwrap();
    assert!(
        user.roles.contains(&Role::Monitor),
        "GRANT monitor TO <user> must add the Monitor role; roles = {:?}",
        user.roles
    );
    // Regression guard against the silent misroute: no object-permission
    // grant may exist — the statement had no `ON` clause.
    assert!(
        state.permissions.snapshot_grants().is_empty(),
        "GRANT monitor TO <user> must not create an object-permission \
         grant; grants = {:?}",
        state.permissions.snapshot_grants()
    );
}

#[tokio::test]
async fn grant_comma_separated_roles() {
    // SQL-standard multi-role grant.
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER multi WITH PASSWORD 'pass'").await;

    ddl_ok(&state, &su, "GRANT readonly, readwrite TO multi").await;

    let user = state.credentials.get_user("multi").unwrap();
    assert!(
        user.roles.contains(&Role::ReadOnly) && user.roles.contains(&Role::ReadWrite),
        "GRANT a, b TO <user> must add every listed role; roles = {:?}",
        user.roles
    );
}

#[tokio::test]
async fn grant_comma_separated_permissions() {
    // Advertised in docs/security/rbac.md: `GRANT INSERT, UPDATE ON orders ...`.
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER analyst WITH PASSWORD 'pass'").await;

    ddl_ok(&state, &su, "GRANT SELECT, INSERT ON orders TO analyst").await;

    let perms: Vec<Permission> = state
        .permissions
        .snapshot_grants()
        .into_iter()
        .filter(|g| g.grantee == "user:analyst")
        .map(|g| g.permission)
        .collect();
    assert!(
        perms.contains(&Permission::Read) && perms.contains(&Permission::Write),
        "GRANT a, b ON <obj> TO <grantee> must grant every listed \
         permission; granted = {perms:?}"
    );
}

#[tokio::test]
async fn grant_role_to_role_membership() {
    // `GRANT <parent_role> TO <child_role>` makes the child inherit the
    // parent — the role-hierarchy form of role membership.
    let state = make_state_with_catalog();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE ROLE mae8_ingester").await;
    ddl_ok(&state, &su, "CREATE ROLE mae8_contributor").await;

    ddl_ok(&state, &su, "GRANT mae8_ingester TO mae8_contributor").await;

    let child = state.roles.get_role("mae8_contributor").unwrap();
    assert_eq!(
        child.parent.as_deref(),
        Some("mae8_ingester"),
        "GRANT <role> TO <role> must establish role inheritance"
    );
}

#[tokio::test]
async fn grant_multiple_roles_to_role_rejected() {
    // The role hierarchy permits a single parent, so granting more than one
    // role to a role must fail with a clear error rather than silently
    // dropping all but one.
    let state = make_state_with_catalog();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE ROLE mae8_ingester").await;
    ddl_ok(&state, &su, "CREATE ROLE mae8_dreamer").await;
    ddl_ok(&state, &su, "CREATE ROLE mae8_contributor").await;

    let err = ddl_err(
        &state,
        &su,
        "GRANT mae8_ingester, mae8_dreamer TO mae8_contributor",
    )
    .await;
    assert!(
        err.contains("only one parent"),
        "expected single-parent rejection, got: {err}"
    );
}

#[tokio::test]
async fn grant_execute_on_procedure() {
    // `GRANT EXECUTE ON PROCEDURE ...` is advertised in docs/security/rbac.md.
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER engineer WITH PASSWORD 'pass'").await;

    ddl_ok(
        &state,
        &su,
        "GRANT EXECUTE ON PROCEDURE transfer_funds TO engineer",
    )
    .await;

    let grants = state.permissions.snapshot_grants();
    assert!(
        grants.iter().any(|g| g.grantee == "user:engineer"
            && g.permission == Permission::Execute
            && g.target.starts_with("procedure:")
            && g.target.ends_with(":transfer_funds")),
        "GRANT EXECUTE ON PROCEDURE must store a procedure-targeted grant; \
         grants = {grants:?}"
    );
}

#[tokio::test]
async fn grant_role_to_role_cycle_rejected() {
    // base ← mid (mid inherits base). Granting mid to base would close the
    // loop base → mid → base — it must be rejected at write time, not left
    // to surface as a depth error when the chain is later resolved.
    let state = make_state_with_catalog();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE ROLE base").await;
    ddl_ok(&state, &su, "CREATE ROLE mid").await;
    ddl_ok(&state, &su, "GRANT base TO mid").await;

    let err = ddl_err(&state, &su, "GRANT mid TO base").await;
    assert!(
        err.to_lowercase().contains("cycle"),
        "expected an inheritance-cycle rejection, got: {err}"
    );

    // The rejected grant must not have mutated base's parent.
    assert!(
        state
            .roles
            .get_role("base")
            .and_then(|r| r.parent)
            .is_none(),
        "a rejected role-to-role grant must leave the role unchanged"
    );
}

#[tokio::test]
async fn grant_role_to_itself_rejected() {
    let state = make_state_with_catalog();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE ROLE solo").await;

    let err = ddl_err(&state, &su, "GRANT solo TO solo").await;
    assert!(
        err.to_lowercase().contains("cycle"),
        "a role must not be able to inherit from itself, got: {err}"
    );
}

/// `CREATE ROLE IF NOT EXISTS <name>` creates a role named `<name>`, not
/// one named after the `IF` clause keyword.
#[tokio::test]
async fn create_role_if_not_exists_names_real_role() {
    let state = make_state_with_catalog();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE ROLE IF NOT EXISTS auditor").await;

    let log = state.audit.lock().unwrap();
    let details: Vec<&String> = log
        .query_by_event(&AuditEvent::PrivilegeChange)
        .iter()
        .map(|e| &e.detail)
        .collect();
    assert!(
        details.iter().any(|d| d.contains("created role 'auditor'")),
        "{details:?}"
    );
    // Regression guard: the `IF NOT EXISTS` keywords must never leak
    // into the role name.
    assert!(
        !details.iter().any(|d| d.contains("created role 'IF'")),
        "clause keyword used as role name: {details:?}"
    );
}

/// `DROP ROLE IF EXISTS <name>` on a role that does not exist is a no-op
/// success, not an error.
#[tokio::test]
async fn drop_role_if_exists_missing_is_noop() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "DROP ROLE IF EXISTS ghost").await;
}

/// `DROP ROLE IF EXISTS <name>` on an existing role actually drops it —
/// the `IF EXISTS` clause must not turn the statement into a total no-op.
#[tokio::test]
async fn drop_role_if_exists_existing_drops() {
    let state = make_state_with_catalog();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE ROLE auditor").await;
    ddl_ok(&state, &su, "DROP ROLE IF EXISTS auditor").await;

    let log = state.audit.lock().unwrap();
    let details: Vec<&String> = log
        .query_by_event(&AuditEvent::PrivilegeChange)
        .iter()
        .map(|e| &e.detail)
        .collect();
    assert!(
        details.iter().any(|d| d.contains("dropped role 'auditor'")),
        "{details:?}"
    );
}

/// `ALTER ROLE <name> <unknown>` — the parser currently has a catch-all that
/// silently rewrites any unrecognized sub-command into `AlterRoleOp::SetInherit`
/// with an empty parent. Same systemic flaw as the ALTER USER fall-through:
/// unknown syntax must produce a clear parser error naming the actual input,
/// not be silently rerouted into a default variant.
#[tokio::test]
async fn alter_role_unknown_subcommand_rejected_cleanly() {
    let state = make_state_with_catalog();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE ROLE auditor").await;

    let err = ddl_err(&state, &su, "ALTER ROLE auditor FROBNICATE foo").await;

    assert!(
        err.to_uppercase().contains("FROBNICATE"),
        "error must name the unrecognized token, not silently reroute to SET INHERIT: {err}"
    );
}