gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
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
//! **Company single sign-on: the rows** (owner, 2026-10-05: "SSO for
//! companies in gunnar, OIDC and SAML, so a company's people log in with their
//! own identity system" — and SCIM provisioning beside it).
//!
//! The account model is the owner's and does not move:
//!
//! * one account type; its creator is its admin;
//! * an **enterprise** account is an account whose admin enrolled employees,
//!   and an employee is a NEW user, created by and bound to the account
//!   ([`crate::AuthTable::enroll_employee`]) — an existing principal never
//!   becomes one;
//! * private by default; only the account admin makes anything public.
//!
//! SSO changes none of that. An account admin points the account at the
//! company's identity provider; a person the IdP vouches for is mapped to an
//! EMPLOYEE of THAT account — created just in time on the first login (a new
//! employee, never an attached principal), or provisioned beforehand by the
//! company's directory (SCIM, a CSV import) and claimed by its directory user
//! name on its first login. Every row here lives in the identity table
//! (`refs/gunnar/auth`), in the same compare-and-swap as the principal it
//! names, and reaches the twin with it.
//!
//! **No secret is in any row.** The OIDC client secret is sealed in the
//! secret store under [`client_secret_key`] (the box's KEK, replicated sealed);
//! a SCIM bearer is kept only as its SHA-256.

use crate::model::UnixMillis;

/// The protocol an account's identity provider speaks.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SsoProtocol {
    /// OpenID Connect.
    Oidc,
    /// SAML 2.0.
    Saml,
}

/// An OIDC identity provider (authorization code + PKCE).
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OidcSettings {
    /// The issuer, exactly as its discovery document names it.
    pub issuer: String,
    /// The client id the IdP issued for this account's console.
    pub client_id: String,
    /// Beside `openid`, which is always asked.
    #[serde(default)]
    pub scopes: Vec<String>,
    /// The claim that names the person in the company's directory
    /// (`preferred_username` when empty; `email` when that is absent).
    #[serde(default)]
    pub username_claim: String,
    /// The claim shown as the person's name (`name` when empty).
    #[serde(default)]
    pub display_name_claim: String,
    /// **The IdP's own CA** (DER, base64, standard alphabet): the account
    /// admin's trust anchor for a company IdP behind a private CA — used for
    /// THIS provider's https calls alone (discovery, keys, token), beside the
    /// public roots (`gunnar_grpc::sso::idp_ca::parse_idp_ca` decides what is one). Written only when
    /// set, so a gunnar from before it reads every provider that has none.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub idp_ca: Vec<String>,
}

/// An IdP CA as the identity table keeps it (base64 DER, [`OidcSettings::idp_ca`]).
pub fn idp_ca_stored(ders: &[Vec<u8>]) -> Vec<String> {
    use base64ct::{Base64, Encoding as _};
    ders.iter().map(|d| Base64::encode_string(d)).collect()
}

/// [`idp_ca_stored`] read back; an entry that does not decode is dropped.
pub fn idp_ca_from_stored(stored: &[String]) -> Vec<Vec<u8>> {
    use base64ct::{Base64, Encoding as _};
    stored
        .iter()
        .filter_map(|s| Base64::decode_vec(s.trim()).ok())
        .collect()
}

/// A SAML 2.0 identity provider (HTTP-Redirect out, HTTP-POST back).
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SamlSettings {
    /// The IdP's entity id (the assertion's issuer).
    pub idp_entity_id: String,
    /// Where the AuthnRequest goes (HTTP-Redirect binding).
    pub idp_sso_url: String,
    /// The IdP's signing certificates, DER, base64 (standard alphabet). The
    /// ONLY keys a response is verified with — a key in the message is never
    /// trusted.
    pub idp_certificates: Vec<String>,
    /// Where the metadata was read from, when it was (an https URL).
    #[serde(default)]
    pub metadata_url: String,
    /// The attribute that names the person (the NameID when empty).
    #[serde(default)]
    pub username_attribute: String,
    #[serde(default)]
    /// The attribute shown as the person's name.
    pub display_name_attribute: String,
}

/// **An account's identity provider**, as its admin configured it. Keyed by
/// the account.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SsoProvider {
    /// The account (its principal id and namespace).
    pub account: String,
    /// OIDC or SAML.
    pub protocol: SsoProtocol,
    /// "Sign in with <display_name>".
    pub display_name: String,
    /// Email domains that lead the login page to this account (lowercase,
    /// unique on the server: one domain, one company).
    #[serde(default)]
    pub domains: Vec<String>,
    #[serde(default)]
    /// Set when `protocol` is OIDC.
    pub oidc: Option<OidcSettings>,
    #[serde(default)]
    /// Set when `protocol` is SAML.
    pub saml: Option<SamlSettings>,
    /// The account's EMPLOYEES log in through the IdP only: a passkey alone
    /// is refused for them. The account admin is not an employee and keeps
    /// the passkey (the break-glass).
    #[serde(default)]
    pub require_sso: bool,
    /// After the IdP, a passkey of the same person (registered on the first
    /// login when there is none yet).
    #[serde(default)]
    pub require_passkey: bool,
    /// The console the redirect URI and the SAML ACS hang off
    /// (`https://gunnar.example`), as the admin's console named it.
    pub console_origin: String,
    #[serde(default)]
    /// When it was last configured.
    pub updated: UnixMillis,
    #[serde(default)]
    /// Who configured it last.
    pub updated_by: String,
    /// The `domains` whose DNS proof was read (A34-5): only these lead a sign-in
    /// here, count for a claim or a JIT employee, and are held against other
    /// accounts. Written only when set, as `idp_ca`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub verified_domains: Vec<String>,
    /// Who may become a new employee just in time (A34-25): `open` (empty),
    /// `domains` or `off` (`nornir_identity_sso::rules::JitPolicy`).
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub jit: String,
}

impl SsoProvider {
    /// The OIDC redirect URI the admin registers at the IdP.
    pub fn redirect_uri(&self) -> String {
        redirect_uri(&self.console_origin)
    }

    /// The SAML SP entity id (the audience).
    pub fn sp_entity_id(&self) -> String {
        sp_entity_id(&self.console_origin, &self.account)
    }

    /// The SAML Assertion Consumer Service URL.
    pub fn acs_url(&self) -> String {
        acs_url(&self.console_origin)
    }

    /// The issuer a subject is bound under: the OIDC issuer, or the SAML
    /// IdP's entity id.
    pub fn issuer(&self) -> &str {
        match self.protocol {
            SsoProtocol::Oidc => self.oidc.as_ref().map_or("", |o| o.issuer.as_str()),
            SsoProtocol::Saml => self.saml.as_ref().map_or("", |s| s.idp_entity_id.as_str()),
        }
    }
}

/// `<origin>/auth/sso/oidc/callback`.
pub fn redirect_uri(origin: &str) -> String {
    format!("{}/auth/sso/oidc/callback", origin.trim_end_matches('/'))
}

/// `<origin>/auth/sso/saml/<account>` — the SP entity id, one per account so
/// two companies' IdPs never share an audience.
pub fn sp_entity_id(origin: &str, account: &str) -> String {
    format!("{}/auth/sso/saml/{account}", origin.trim_end_matches('/'))
}

/// `<origin>/auth/sso/saml/acs`.
pub fn acs_url(origin: &str) -> String {
    format!("{}/auth/sso/saml/acs", origin.trim_end_matches('/'))
}

/// Where a directory user came from.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SsoSource {
    /// Created just in time by its first SSO login.
    Jit,
    /// Provisioned by the company's directory over SCIM.
    Scim,
    /// Provisioned by the account admin's CSV import.
    Csv,
}

impl SsoSource {
    /// The word a listing shows.
    pub fn as_str(self) -> &'static str {
        match self {
            SsoSource::Jit => "jit",
            SsoSource::Scim => "scim",
            SsoSource::Csv => "csv",
        }
    }
}

/// **One person of a company's directory**: an EMPLOYEE of `account`, keyed
/// by its principal id. Written in the same compare-and-swap as the employee
/// it describes, removed with it.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SsoUser {
    /// The employee.
    pub principal_id: String,
    /// The account it is an employee of.
    pub account: String,
    /// The directory's user name, lowercase (SCIM `userName`, the mapped
    /// claim or attribute). Unique within the account.
    pub user_name: String,
    #[serde(default)]
    /// The person's name, as the directory says it.
    pub display_name: String,
    #[serde(default)]
    /// The person's address, as the directory says it (never mailed by gunnar).
    pub email: String,
    /// The directory's own id for the person (SCIM `externalId`).
    #[serde(default)]
    pub external_id: String,
    /// [`subject_key`] of the IdP subject this person logs in as, once bound
    /// (by the first login). `None`: provisioned, never logged in yet.
    #[serde(default)]
    pub subject: Option<String>,
    /// False: deactivated — gated, sessions revoked, data kept.
    pub active: bool,
    /// Where the row came from.
    pub source: SsoSource,
    /// When it was created.
    pub created: UnixMillis,
    #[serde(default)]
    /// When its last SSO login was.
    pub last_login: Option<UnixMillis>,
    /// The web session keys SSO logins enrolled (fingerprints), so a
    /// deactivation ends them. Pruned at each login.
    #[serde(default)]
    pub sessions: Vec<String>,
    /// Removed from the directory (SCIM DELETE): still gated, data kept, no
    /// longer listed or claimable; its user name is free for a NEW person.
    #[serde(default)]
    pub deleted: bool,
    /// The account admin opened a rebind (A34-11, an IdP migration): unbound
    /// from its old subject, its next sign-in binds it afresh on the claim rules.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub rebind: bool,
}

/// A SCIM bearer, by the SHA-256 of its secret.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScimToken {
    /// The public id a listing and a revocation name.
    pub id: String,
    /// The account the bearer provisions.
    pub account: String,
    /// Lowercase hex SHA-256 of the secret. Never the secret.
    pub secret_hash: String,
    #[serde(default)]
    /// What the account admin called it.
    pub label: String,
    /// When it was created.
    pub created: UnixMillis,
    /// Who created it.
    pub created_by: String,
    #[serde(default)]
    /// When it was last presented (to a minute).
    pub last_used: Option<UnixMillis>,
    /// When it stops working (A34-27); `None` never. Not written when `None`,
    /// so a row without one reads as before.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expires: Option<UnixMillis>,
    /// It may only read the directory (A34-27). Not written when false.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub read_only: bool,
}

/// A SCIM group of an account's directory. Kept as the directory sent it;
/// what a group may reach is granted by the account admin.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SsoGroup {
    /// The group's id (SCIM `id`).
    pub id: String,
    /// The account whose directory it is in.
    pub account: String,
    /// The group's name (SCIM `displayName`), unique within the account.
    pub display_name: String,
    #[serde(default)]
    /// The directory's own id for the group (SCIM `externalId`).
    pub external_id: String,
    /// Principal ids (employees of the same account only).
    #[serde(default)]
    pub members: Vec<String>,
    /// When it was created.
    pub created: UnixMillis,
}

/// The secret-store key of an account's OIDC client secret.
pub fn client_secret_key(account: &str) -> String {
    format!("sso/{account}/oidc-client-secret")
}

/// The gate holder a directory deactivation writes, so a reactivation lifts
/// only its own gate and never an operator's suspension.
pub fn gate_holder(account: &str) -> String {
    format!("sso:{account}")
}

/// **The key a subject is bound under**: SHA-256 of `issuer ␟ subject`, hex.
/// Hashed so a listing of the identity table carries no IdP identifiers.
pub fn subject_key(issuer: &str, subject: &str) -> String {
    nornir_identity_sso::rules::subject_key(issuer, subject)
}

/// The longest principal id a console carries in a session.
pub const MAX_ID_LEN: usize = 64;

/// **The ids a NEW employee for `user_name` may take**, in order:
/// `<account>.<local>`, then `-2` … `-9`, then short suffixes derived from the
/// whole user name (A34-42: the 10th person with the same local part was
/// refused) — the local part of an address (or the whole name) reduced to
/// `[a-z0-9._-]`. Never an existing principal: the caller takes the first one
/// that is free and creates it. The rule is edda's
/// ([`nornir_identity_directory::names::principal_id_candidates`]), this
/// crate adds its id length and component rule.
pub fn principal_id_candidates(account: &str, user_name: &str) -> Vec<String> {
    nornir_identity_directory::names::principal_id_candidates(
        account,
        user_name,
        MAX_ID_LEN,
        |id| crate::sandbox::check_component(id, id).is_ok(),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn candidates_are_the_accounts_and_valid_ids() {
        let ids = principal_id_candidates("acme", "Bob.Smith@ACME.example");
        assert_eq!(ids[0], "acme.bob.smith");
        assert_eq!(ids[1], "acme.bob.smith-2");
        assert_eq!(ids.len(), 13, "-1..-9 and four hashed suffixes: {ids:?}");
        let odd = principal_id_candidates("acme", "  ..--åsa ö'connor--..");
        assert_eq!(odd[0], "acme.sa-connor");
        let empty = principal_id_candidates("acme", "@@@");
        assert_eq!(empty[0], "acme.user");
        let long = principal_id_candidates("acme", &"x".repeat(200));
        assert!(long.iter().all(|id| id.len() <= MAX_ID_LEN));
    }

    /// A provider with no CA is written as before: a gunnar from before the
    /// CA reads it (`deny_unknown_fields`).
    #[test]
    fn a_provider_without_a_ca_is_written_as_before() {
        let o = OidcSettings {
            issuer: "https://idp.example".into(),
            client_id: "c".into(),
            ..Default::default()
        };
        let text = serde_json::to_string(&o).expect("json");
        assert!(!text.contains("idp_ca"), "{text}");
        let with = OidcSettings {
            idp_ca: vec!["AAAA".into()],
            ..o
        };
        assert!(serde_json::to_string(&with)
            .expect("json")
            .contains("idp_ca"));
    }

    #[test]
    fn subject_keys_bind_the_issuer() {
        assert_ne!(
            subject_key("https://a.example", "1"),
            subject_key("https://b.example", "1")
        );
        assert_eq!(
            subject_key("https://a.example/", "1"),
            subject_key("https://a.example", "1")
        );
        assert_eq!(subject_key("i", "s").len(), 64);
    }
}