nodedb 0.3.0-beta.1

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
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
// SPDX-License-Identifier: BUSL-1.1

//! CREATE/DROP/ALTER USER over the pgwire DDL path, plus the two
//! readonly-permission guards that live on user-mgmt surfaces.

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::credential::store::CredentialStore;
use nodedb::control::security::identity::Role;
use nodedb::types::TenantId;

#[tokio::test]
async fn create_user() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER alice WITH PASSWORD 'secret123' ROLE readwrite TENANT 1",
    )
    .await;

    let user = state.credentials.get_user("alice").unwrap();
    assert_eq!(user.tenant_id, TenantId::new(1));
    assert!(user.roles.contains(&Role::ReadWrite));
    assert!(!user.is_superuser);
}

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

    let err = ddl_err(&state, &su, "CREATE USER bob WITH PASSWORD 'pass2'").await;
    assert!(
        err.contains("already exists"),
        "expected duplicate error: {err}"
    );
}

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

    let user = state.credentials.get_user("carol").unwrap();
    // Default role is readwrite, tenant inherited from identity (0 for superuser).
    assert!(user.roles.contains(&Role::ReadWrite));
}

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

    assert!(state.credentials.get_user("dave").is_none());
}

#[tokio::test]
async fn drop_self_rejected() {
    let state = make_state();
    let su = superuser();
    let err = ddl_err(&state, &su, "DROP USER nodedb").await;
    assert!(err.contains("cannot drop your own"), "{err}");
}

#[tokio::test]
async fn drop_nonexistent_user() {
    let state = make_state();
    let su = superuser();
    let err = ddl_err(&state, &su, "DROP USER nobody").await;
    assert!(err.contains("does not exist"), "{err}");
}

#[tokio::test]
async fn alter_user_password() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER eve WITH PASSWORD 'old'").await;
    ddl_ok(&state, &su, "ALTER USER eve SET PASSWORD 'new'").await;

    assert!(state.credentials.verify_password("eve", "new"));
    assert!(!state.credentials.verify_password("eve", "old"));
}

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

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

#[tokio::test]
async fn drop_then_recreate_same_name() {
    // DROP USER must fully free the username. Recreating a dropped
    // user with the same name (the routine "rotate credentials"
    // operation) must succeed — not fail with "already exists".
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER demo2 WITH PASSWORD 'oldpass' ROLE readwrite TENANT 2",
    )
    .await;
    ddl_ok(&state, &su, "DROP USER demo2").await;
    assert!(state.credentials.get_user("demo2").is_none());

    ddl_ok(
        &state,
        &su,
        "CREATE USER demo2 WITH PASSWORD 'newpass' ROLE readwrite TENANT 2",
    )
    .await;

    // The recreated user must be a fresh, active record — not the
    // stale tombstone resurrected with its old credentials.
    let user = state
        .credentials
        .get_user("demo2")
        .expect("recreated user must be visible");
    assert!(user.is_active);
    assert!(
        state.credentials.verify_password("demo2", "newpass"),
        "recreated user must carry the new password"
    );
    assert!(
        !state.credentials.verify_password("demo2", "oldpass"),
        "stale credentials from the dropped user must not survive"
    );
}

#[test]
fn dropped_username_is_free_after_restart() {
    // The stale identity record must not survive a daemon restart.
    // A username dropped before restart must be reusable after the
    // catalog is reloaded from disk.
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("system.redb");
    {
        let store = CredentialStore::open(&path).unwrap();
        store
            .create_user("demo2", "oldpass", TenantId::new(2), vec![Role::ReadWrite])
            .unwrap();
        assert!(store.drop_user("demo2").unwrap());
    }

    // Simulate the daemon restart: reopen the same on-disk catalog.
    let store = CredentialStore::open(&path).unwrap();
    store
        .create_user("demo2", "newpass", TenantId::new(2), vec![Role::ReadWrite])
        .expect("recreating a dropped user after restart must succeed");

    let user = store
        .get_user("demo2")
        .expect("recreated user must be visible after restart");
    assert!(user.is_active);
}

#[test]
fn dropped_username_is_free_for_service_account() {
    // The CREATE-time uniqueness check for service accounts shares
    // the user uniqueness store. A name freed by DROP USER must be
    // available to a new service account.
    let store = CredentialStore::new();
    store
        .create_user("demo2", "oldpass", TenantId::new(2), vec![Role::ReadWrite])
        .unwrap();
    assert!(store.drop_user("demo2").unwrap());

    store
        .create_service_account("demo2", TenantId::new(2), vec![Role::ReadWrite], vec![])
        .expect("a dropped user's name must be free for a service account");
}

#[tokio::test]
async fn readonly_cannot_create_user() {
    let state = make_state();
    assert_readonly_denied(&state, "CREATE USER hacker WITH PASSWORD 'x'").await;
}

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

    assert_readonly_denied(&state, "DROP USER target").await;
}

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

/// `DROP USER IF EXISTS <name>` on an existing user actually drops it —
/// the `IF EXISTS` clause must not turn the statement into a total no-op.
#[tokio::test]
async fn drop_user_if_exists_existing_drops() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER target WITH PASSWORD 'pass'").await;
    ddl_ok(&state, &su, "DROP USER IF EXISTS target").await;

    assert!(
        state.credentials.get_user("target").is_none(),
        "DROP USER IF EXISTS must drop an existing user"
    );
}

/// `CREATE USER ... TENANT '<name>'` resolves the tenant by name, so
/// admins are not forced to look up numeric ids from `SHOW TENANTS`.
#[tokio::test]
async fn create_user_tenant_by_name() {
    let state = make_state_with_catalog();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE TENANT acme ID 42").await;
    ddl_ok(
        &state,
        &su,
        "CREATE USER alice WITH PASSWORD 'secret123' TENANT 'acme'",
    )
    .await;

    let user = state.credentials.get_user("alice").unwrap();
    assert_eq!(
        user.tenant_id,
        TenantId::new(42),
        "TENANT '<name>' must resolve to the named tenant's id"
    );
}

/// `CREATE USER IF NOT EXISTS <name>` creates a user named `<name>`, not
/// one named after the `IF` clause keyword.
#[tokio::test]
async fn create_user_if_not_exists_names_real_user() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER IF NOT EXISTS alice WITH PASSWORD 'pw'",
    )
    .await;

    assert!(
        state.credentials.get_user("alice").is_some(),
        "user must be created under its real name"
    );
    assert!(
        state.credentials.get_user("IF").is_none(),
        "clause keyword must not be created as a user"
    );
}

/// A second `CREATE USER IF NOT EXISTS <name>` for an existing user is a
/// no-op success, not an `already exists` error.
#[tokio::test]
async fn create_user_if_not_exists_is_idempotent() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER alice WITH PASSWORD 'pw'").await;
    ddl_ok(
        &state,
        &su,
        "CREATE USER IF NOT EXISTS alice WITH PASSWORD 'pw2'",
    )
    .await;
}

// ---------------------------------------------------------------------------
// Unknown `ALTER USER` syntax must surface a clear parse-level error and
// must NOT be silently rewritten into a default `AlterUserOp` variant.
//
// The parser's catch-all arms in `nodedb-sql/src/ddl_ast/parse/user_auth.rs`
// currently fall back to `AlterUserOp::SetRole { role: "" }` (and similar
// silent defaults) whenever the sub-command doesn't match. That produces
// misleading downstream errors that reference an internal AST form (e.g.
// "expected role name after SET ROLE") even though the user never typed
// `SET ROLE`, and in the PASSWORD branch can silently execute a destructive
// `PasswordNeverExpires` for unknown PASSWORD sub-forms. The fix is parser-
// level: unknown ALTER USER syntax must be rejected with a message that
// names the actually-typed input, not an internal default variant.
// ---------------------------------------------------------------------------

/// `ALTER USER <name> ROLE <role>` (no `SET`) — the reported bug. PostgreSQL
/// accepts both spellings, and `CREATE USER ... ROLE ...` uses the keyword
/// without `SET`, so the natural parallel form must be accepted. The role
/// must actually change.
#[tokio::test]
async fn alter_user_role_without_set_keyword_changes_role() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER eman WITH PASSWORD 'pw' ROLE readonly",
    )
    .await;

    ddl_ok(&state, &su, "ALTER USER eman ROLE tenant_admin").await;

    let user = state.credentials.get_user("eman").unwrap();
    assert!(
        user.roles.contains(&Role::TenantAdmin),
        "ALTER USER ... ROLE <role> must update the role: {:?}",
        user.roles
    );
    assert!(!user.roles.contains(&Role::ReadOnly));
}

/// `ALTER USER <name> WITH ROLE <role>` — another natural variant called out
/// in the bug report. Accepted as a synonym for `SET ROLE`.
#[tokio::test]
async fn alter_user_with_role_changes_role() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER eman WITH PASSWORD 'pw' ROLE readonly",
    )
    .await;

    ddl_ok(&state, &su, "ALTER USER eman WITH ROLE tenant_admin").await;

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

/// The empty-role spelling of the accepted alias still has to be rejected
/// — `ALTER USER <name> ROLE` with no role name must error rather than
/// silently routing into the internal `SetRole { role: "" }` default that
/// produced the original misleading "expected role name after SET ROLE"
/// message.
#[tokio::test]
async fn alter_user_role_alias_without_role_name_rejected_cleanly() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER eman WITH PASSWORD 'pw' ROLE readonly",
    )
    .await;

    let err = ddl_err(&state, &su, "ALTER USER eman ROLE").await;

    assert!(
        !err.to_lowercase()
            .contains("expected role name after set role"),
        "must not surface the misleading legacy wording: {err}"
    );
    let user = state.credentials.get_user("eman").unwrap();
    assert!(user.roles.contains(&Role::ReadOnly));
}

/// `ALTER USER <name> SET <unknown> ...` — the SET branch's catch-all also
/// silently rewrites to `SetRole { role: "" }`. The parser must reject this.
#[tokio::test]
async fn alter_user_set_unknown_action_rejected_cleanly() {
    let state = make_state();
    let su = superuser();
    ddl_ok(
        &state,
        &su,
        "CREATE USER eman WITH PASSWORD 'pw' ROLE readonly",
    )
    .await;

    let err = ddl_err(&state, &su, "ALTER USER eman SET FOO bar").await;

    assert!(
        err.to_uppercase().contains("FOO") || err.to_uppercase().contains("UNKNOWN"),
        "error must name the unrecognized token, not silently route to SET ROLE: {err}"
    );

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

/// `ALTER USER <name> PASSWORD <garbage>` — the PASSWORD branch's catch-all
/// currently silently falls through to `PasswordNeverExpires`, which is a
/// destructive privilege change executed with no user input. Parser must
/// reject unknown PASSWORD sub-forms instead of silently executing a default.
#[tokio::test]
async fn alter_user_password_unknown_subform_does_not_silently_execute() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER eman WITH PASSWORD 'pw'").await;
    // Establish a known finite expiry so we can detect a silent overwrite to "never".
    ddl_ok(&state, &su, "ALTER USER eman PASSWORD EXPIRES IN 30 DAYS").await;
    let before = state.credentials.get_user("eman").unwrap();
    let expiry_before = before.password_expires_at;
    assert!(
        expiry_before != 0,
        "test precondition: expiry should be set to a finite value, got {expiry_before}"
    );

    let err = ddl_err(&state, &su, "ALTER USER eman PASSWORD WHATEVER").await;
    assert!(
        err.to_uppercase().contains("WHATEVER") || err.to_uppercase().contains("UNKNOWN"),
        "error must name the unrecognized token: {err}"
    );

    let after = state.credentials.get_user("eman").unwrap();
    assert_eq!(
        after.password_expires_at, expiry_before,
        "rejected ALTER USER PASSWORD must not silently overwrite expiry to 'never' (0)"
    );
}

/// `ALTER USER <name>` with no sub-command at all — must produce a clear
/// syntax error, not a silent SetRole-with-empty-role misdirection.
#[tokio::test]
async fn alter_user_no_subcommand_rejected_cleanly() {
    let state = make_state();
    let su = superuser();
    ddl_ok(&state, &su, "CREATE USER eman WITH PASSWORD 'pw'").await;

    let err = ddl_err(&state, &su, "ALTER USER eman").await;
    assert!(
        !err.to_lowercase()
            .contains("expected role name after set role"),
        "bare ALTER USER must not be misreported as a SET ROLE failure: {err}"
    );
}