Manage users from code
Create, update, and permission users programmatically via IProcedureService — no console or CLI required.
The admin console and sd CLI are convenience wrappers around sp_user_* system procedures. Anything they can do, your own code can do directly through IProcedureService — useful for boot-time seeding, automated provisioning, or wiring an external identity system into SmartData.
Why IProcedureService and not IAuthenticatedProcedureService
IProcedureService runs trusted: framework authority, auth gate bypassed, every RequestIdentity.Require* check passes silently, audit rows attribute to "system". No token, no login, no User:Create grant — nothing to set up. That's exactly what you want for code that runs before any user has logged in (startup seeders) and for code that acts on the server's own behalf (background sync, scheduled jobs).
The trust flag inherits through nested ctx.ExecuteAsync calls, so calling sp_user_create from inside another trusted procedure works the same way. The trust split — and what "trusted" actually means at the executor boundary — is covered in Procedures → What "trusted" actually means.
If you instead need to act as a specific signed-in user (e.g. an admin invoking these from a controller), inject IAuthenticatedProcedureService and the existing permission gate (Permissions.UserCreate) will apply.
Seed an admin at startup
Idempotent pattern — safe to run on every boot. IProcedureService is a singleton (it forwards to the executor, which manages its own per-call scope), so resolve it directly off app.Services:
using SmartData;
using SmartData.Server;
using SmartData.Server.Procedures; // ProcedureException
var app = builder.Build();
var procs = app.Services.GetRequiredService<IProcedureService>();
try
{
await procs.ExecuteAsync<string>("sp_user_create", new
{
Username = "admin",
Password = builder.Configuration["Bootstrap:AdminPassword"]!,
IsAdmin = true,
});
}
catch (ProcedureException ex) when (ex.Message.Contains("already exists"))
{
// already seeded — fine
}
app.UseSmartData();
app.Run();
IsAdmin = true short-circuits the permission table — admins implicitly hold every system and scoped permission (see SessionManager.LoadPermissions).
Create a regular user with scoped permissions
await procs.ExecuteAsync<string>("sp_user_create", new
{
Username = "alice",
Password = "...",
// IsAdmin defaults to false
});
// sp_user_create returns a confirmation string, not the new id.
// Look the user up to grant permissions:
var users = await procs.ExecuteAsync<UserListResult>("sp_user_list");
var alice = users.Items.First(u => u.Username == "alice");
await procs.ExecuteAsync<string>("sp_user_permission_grant", new
{
UserId = alice.Id,
PermissionKey = "customer:read",
});
PermissionKey values are whatever you've registered in Permissions.System / Permissions.Scoped. See SmartData.Server reference → Permissions for the catalog.
Import a user with a pre-computed hash
sp_user_create accepts either Password (hashed for you) or PasswordHash (stored verbatim) — useful when migrating from an existing system or seeding a deterministic hash from a build pipeline. The two are mutually exclusive.
await procs.ExecuteAsync<string>("sp_user_create", new
{
Username = "alice",
PasswordHash = "<verbatim hash from your old system>",
IsAdmin = false,
});
The stored value is opaque to SmartData — whatever your registered IPasswordHasher.Verify recognizes is fine. The default Pbkdf2PasswordHasher expects {base64-salt}.{base64-hash}. If you're importing from a different format (bcrypt, argon2, …), register a custom IPasswordHasher first; see Customize password hashing — or write an aggregate verifier that recognizes both your legacy format and the SmartData default and opportunistically rehashes on successful login.
Update, disable, delete
// Reset password
await procs.ExecuteAsync<string>("sp_user_update", new
{
UserId = alice.Id,
Password = "...",
});
// Disable (also revokes any active sessions)
await procs.ExecuteAsync<string>("sp_user_update", new
{
UserId = alice.Id,
IsDisabled = true,
});
// Delete (also revokes sessions)
await procs.ExecuteAsync<string>("sp_user_delete", new { UserId = alice.Id });
sp_user_update only touches the fields you provide — every parameter except UserId is nullable.
Run inside a hosted service
For long-lived background work — periodic sync from an external directory, for example — inject IProcedureService directly into a BackgroundService:
public class UserSync(IProcedureService procs) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// …call sp_user_* as needed
await Task.Delay(TimeSpan.FromMinutes(15), ct);
}
}
}
For scheduled sync, prefer turning the work into a stored procedure with [Every] or [Daily] — see Schedule a recurring job.
Audit attribution
Rows created via IProcedureService are attributed to "system". If you want a more meaningful caller name in audit fields, your procedure can accept a CurrentUser parameter and you set it explicitly:
await procs.ExecuteAsync<string>("usp_customer_save", new
{
Id = 123,
CompanyName = "Acme",
CurrentUser = "user-sync-job",
});
Audit conventions are covered in Procedures → Audit fields.
Related
- System procedures — full
sp_user_*catalog - Use the admin console — the GUI alternative
- Procedures → Two callers, one boundary — when to use which service