mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 13:09:56 +00:00
folder defaults: stop at folders shared with anyone extra
Folder access is a union, so a subfolder that inherits from its parent but also grants groups or users of its own admits people the parent doesn't. The defaults walk only stopped at inherit_from_parent: false, so a contractor added to corp/partner through allowed_users could connect with corp's default key. A parent's defaults now flow only into a subfolder that inherits and grants nobody of its own (FolderConfig::shares_parent_audience), tested. Also: - an entry set to prompt is no longer listed as using folder credentials - PUT .../defaults on a folder that doesn't exist is a 404, rather than writing a .defaults that makes an admin-only phantom folder - the folder dialog takes a domain for RDP defaults - docs: the new boundary, and that a $variable in a default resolves per user
This commit is contained in:
@@ -59,7 +59,7 @@ Vault KV v2 path structure:
|
||||
- `<base_path>/shared/<folder>/<entry>` — shared across all instances
|
||||
- `<base_path>/instance/<name>/<folder>/<entry>` — instance-specific
|
||||
- `<folder>/.config` — folder metadata: `{"allowed_groups":["group1"], "description":"..."}`
|
||||
- `<folder>/.defaults` — default credentials per session type, lent to entries without their own; subfolders inherit them up to (not past) a folder with `inherit_from_parent: false`
|
||||
- `<folder>/.defaults` — default credentials per session type, lent to entries without their own; subfolders inherit them only while the subfolder has `inherit_from_parent: true` and no `allowed_groups`/`allowed_users` of its own
|
||||
- `<base_path>/users/<sanitized_email>` — per-user credential variables
|
||||
|
||||
#### Multiple Vault backends (DR)
|
||||
|
||||
+1
-1
@@ -613,7 +613,7 @@ Entries in other folders that merely share the name are never touched. Responses
|
||||
|
||||
### `GET /api/addressbook/folders/:scope/:folder/defaults` (admin)
|
||||
|
||||
The folder's default credentials, secrets stripped. These are lent to entries in the folder (and in subfolders without their own, per session type, up to but not past a subfolder with `inherit_from_parent: false`) that have no password or private key of their own, so an entry created without secrets by a webhook or an import is connectable the moment it exists. An entry's own credentials always win, and an entry set to `prompt_credentials` still prompts. See [Folder default credentials](credential-variables.md#folder-default-credentials) for the storage layout, the inheritance rule and the note on web entries.
|
||||
The folder's default credentials, secrets stripped. These are lent to entries in the folder (and in subfolders without their own, per session type, but only subfolders that inherit access and grant no groups or users of their own) that have no password or private key of their own, so an entry created without secrets by a webhook or an import is connectable the moment it exists. An entry's own credentials always win, and an entry set to `prompt_credentials` still prompts. See [Folder default credentials](credential-variables.md#folder-default-credentials) for the storage layout, the inheritance rule and the note on web entries.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -162,11 +162,18 @@ At connect time, an entry with no password or private key of its own, and not se
|
||||
|
||||
Entries covered by defaults list with `has_credentials: true` and `inherited_credentials: true`, and show a `[folder]` marker in Connections.
|
||||
|
||||
A default password or username can be a credential variable such as `$tenant_password`. Defaults are applied before variables are resolved, so each user connects with their own value from My Credentials. The "credentials needed" marker in Connections only checks an entry's own fields, so it won't flag a variable that comes from a folder default.
|
||||
|
||||
### Inheritance and the `inherit_from_parent` boundary
|
||||
|
||||
A subfolder without its own `.defaults` uses its nearest ancestor's, per session type: the nearest folder on the path wins for each type, and a type it lacks comes from the next folder up.
|
||||
|
||||
The walk up the tree follows the same rule as access: it continues past a folder only while that folder's `.config` has `inherit_from_parent: true`. A folder with `inherit_from_parent: false` is isolated for access (see [Roles and access control](roles-and-access-control.md#inheritance)), and defaults set on its parent do not flow into it. Otherwise a parent's key would be usable in a subfolder shared with someone else through `allowed_users`. The isolated folder's own `.defaults` still apply to its entries and to subfolders that inherit from it. A folder with no `.config`, or one written before the flag existed, counts as not inheriting, exactly as the access check treats it.
|
||||
A parent's defaults reach a subfolder only when exactly the parent's audience can open it: the subfolder's `.config` has `inherit_from_parent: true` and grants no `allowed_groups` or `allowed_users` of its own. The walk up the tree stops at any other folder:
|
||||
|
||||
- A folder with `inherit_from_parent: false` is isolated for access (see [Roles and access control](roles-and-access-control.md#inheritance)), so its parent's defaults do not flow into it.
|
||||
- A folder that inherits but also grants groups or users of its own admits people the parent doesn't (access is the union of both), so its parent's defaults do not flow into it either. Otherwise a contractor added to `corp/partner` through `allowed_users` could connect with `corp`'s key.
|
||||
|
||||
The folder where the walk stops still applies its own `.defaults` to its entries and to subfolders that share its audience. A folder with no `.config`, or one written before the flag existed, counts as not inheriting, exactly as the access check treats it.
|
||||
|
||||
### Migration between Vaults
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ Because folder settings live in Vault, they apply on every rustguac instance sha
|
||||
|
||||
A subfolder created with `inherit_from_parent: true` (the default for new subfolders) grants access to anyone who can access its parent. A subfolder with `inherit_from_parent: false` is gated solely by its own `allowed_groups` and `allowed_users`, independent of the parent.
|
||||
|
||||
The same flag bounds [folder default credentials](credential-variables.md#folder-default-credentials): defaults set on a parent do not flow into a subfolder with `inherit_from_parent: false`, so isolating a folder for access also keeps the parent's secrets out of it.
|
||||
The same rules bound [folder default credentials](credential-variables.md#folder-default-credentials): defaults set on a parent flow only into a subfolder that inherits and grants no groups or users of its own. Isolating a folder, or sharing it with anyone the parent doesn't include, keeps the parent's secrets out of it.
|
||||
|
||||
### Examples
|
||||
|
||||
|
||||
+24
-4
@@ -2382,10 +2382,10 @@ impl DefaultsSource for VaultBackends {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn inherits_from_parent(&self, scope: &str, folder: &str) -> Result<bool, VaultError> {
|
||||
async fn takes_parent_defaults(&self, scope: &str, folder: &str) -> Result<bool, VaultError> {
|
||||
self.scoped(scope)
|
||||
.await?
|
||||
.inherits_from_parent(scope, folder)
|
||||
.takes_parent_defaults(scope, folder)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -2687,6 +2687,26 @@ pub async fn ab_put_folder_defaults(
|
||||
}
|
||||
}
|
||||
|
||||
// Only on a folder that exists: writing <path>/.defaults on its own would
|
||||
// make a folder with no .config, which nobody but an admin can see.
|
||||
match vault.get_folder_config(&scope, &folder).await {
|
||||
Ok(_) => {}
|
||||
Err(VaultError::NotFound) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "folder not found"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
let mut current = match vault.get_folder_defaults(&scope, &folder).await {
|
||||
Ok(d) => d,
|
||||
Err(VaultError::NotFound) => crate::vault::FolderDefaults::new(),
|
||||
@@ -7199,7 +7219,7 @@ mod tests {
|
||||
) -> Result<Option<crate::vault::FolderDefaults>, VaultError> {
|
||||
Ok(self.defaults.get(folder).cloned())
|
||||
}
|
||||
async fn inherits_from_parent(
|
||||
async fn takes_parent_defaults(
|
||||
&self,
|
||||
_scope: &str,
|
||||
folder: &str,
|
||||
@@ -7275,7 +7295,7 @@ mod tests {
|
||||
) -> Result<Option<crate::vault::FolderDefaults>, VaultError> {
|
||||
Err(VaultError::Forbidden)
|
||||
}
|
||||
async fn inherits_from_parent(&self, _s: &str, _f: &str) -> Result<bool, VaultError> {
|
||||
async fn takes_parent_defaults(&self, _s: &str, _f: &str) -> Result<bool, VaultError> {
|
||||
Err(VaultError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
+69
-15
@@ -167,21 +167,24 @@ pub trait DefaultsSource {
|
||||
folder: &str,
|
||||
) -> Result<Option<FolderDefaults>, VaultError>;
|
||||
|
||||
/// The folder's `inherit_from_parent` flag. A folder with no `.config`
|
||||
/// Whether the folder's parent may lend it defaults: see
|
||||
/// `FolderConfig::shares_parent_audience`. A folder with no `.config`
|
||||
/// does not inherit, which is how `resolve_folder_access` treats it too.
|
||||
async fn inherits_from_parent(&self, scope: &str, folder: &str) -> Result<bool, VaultError>;
|
||||
async fn takes_parent_defaults(&self, scope: &str, folder: &str) -> Result<bool, VaultError>;
|
||||
}
|
||||
|
||||
/// The defaults that apply to entries in `folder`: its own `.defaults`, then
|
||||
/// each ancestor's for any session type still unset, walking up only while
|
||||
/// the folder just visited has `inherit_from_parent: true`.
|
||||
/// the folder just visited is seen by exactly its parent's audience
|
||||
/// (`FolderConfig::shares_parent_audience`).
|
||||
///
|
||||
/// That flag is how a folder is isolated for access (its own
|
||||
/// `allowed_groups` / `allowed_users` decide, nothing is taken from the
|
||||
/// parent), so a secret set on a parent must not flow past it either: a
|
||||
/// subfolder shared with someone through `allowed_users` would otherwise
|
||||
/// hand them the parent's key. The isolated folder's own `.defaults` still
|
||||
/// apply to its entries. `None` when no folder on the walk has any.
|
||||
/// A secret set on a parent must reach only people who could already reach
|
||||
/// the parent. A folder with `inherit_from_parent: false` is isolated, and
|
||||
/// one that inherits but also grants groups or users of its own admits
|
||||
/// people the parent doesn't, so the walk stops at either: otherwise a
|
||||
/// subfolder shared with a contractor through `allowed_users` would hand
|
||||
/// them the parent's key. That folder's own `.defaults` still apply to its
|
||||
/// entries. `None` when no folder on the walk has any.
|
||||
pub async fn resolve_effective_defaults<S: DefaultsSource>(
|
||||
src: &S,
|
||||
scope: &str,
|
||||
@@ -198,7 +201,7 @@ pub async fn resolve_effective_defaults<S: DefaultsSource>(
|
||||
// Top-level folder: nothing above it to consult.
|
||||
let Some(i) = path.rfind('/') else { break };
|
||||
// Isolated folder: what its parent lends stops here.
|
||||
if !src.inherits_from_parent(scope, &path).await? {
|
||||
if !src.takes_parent_defaults(scope, &path).await? {
|
||||
break;
|
||||
}
|
||||
path.truncate(i);
|
||||
@@ -223,9 +226,9 @@ impl DefaultsSource for VaultClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fn inherits_from_parent(&self, scope: &str, folder: &str) -> Result<bool, VaultError> {
|
||||
async fn takes_parent_defaults(&self, scope: &str, folder: &str) -> Result<bool, VaultError> {
|
||||
match self.get_folder_config(scope, folder).await {
|
||||
Ok(c) => Ok(c.inherit_from_parent),
|
||||
Ok(c) => Ok(c.shares_parent_audience()),
|
||||
Err(VaultError::NotFound) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
@@ -233,6 +236,15 @@ impl DefaultsSource for VaultClient {
|
||||
}
|
||||
|
||||
impl FolderConfig {
|
||||
/// Whether exactly the parent's audience can open this folder: it
|
||||
/// inherits the parent's access and grants nobody of its own. Only then
|
||||
/// may the parent's `.defaults` flow into it; access is a union, so a
|
||||
/// folder that inherits and also has `allowed_groups` / `allowed_users`
|
||||
/// admits people the parent does not.
|
||||
pub fn shares_parent_audience(&self) -> bool {
|
||||
self.inherit_from_parent && self.allowed_groups.is_empty() && self.allowed_users.is_empty()
|
||||
}
|
||||
|
||||
/// Whether this folder's own settings let `who` in: a shared group, or
|
||||
/// their email in `allowed_users`. Inheritance is the caller's concern.
|
||||
pub fn grants(&self, who: FolderSubject<'_>) -> bool {
|
||||
@@ -698,7 +710,9 @@ impl EntryInfo {
|
||||
/// Mark an entry as connectable through folder defaults when it has no
|
||||
/// secret of its own and the defaults cover its session type.
|
||||
pub fn apply_inherited(&mut self, e: &AddressBookEntry, defaults: Option<&FolderDefaults>) {
|
||||
if self.has_credentials {
|
||||
// An entry with secrets of its own, or one set to prompt, never uses
|
||||
// the folder's, so it must not be labelled as if it did.
|
||||
if self.has_credentials || !entry_takes_folder_defaults(e) {
|
||||
return;
|
||||
}
|
||||
if let Some(d) = defaults.and_then(|d| d.get(&e.session_type)) {
|
||||
@@ -2563,7 +2577,7 @@ mod tests {
|
||||
) -> Result<Option<FolderDefaults>, VaultError> {
|
||||
Ok(self.defaults.get(folder).cloned())
|
||||
}
|
||||
async fn inherits_from_parent(
|
||||
async fn takes_parent_defaults(
|
||||
&self,
|
||||
_scope: &str,
|
||||
folder: &str,
|
||||
@@ -2704,7 +2718,7 @@ mod tests {
|
||||
) -> Result<Option<FolderDefaults>, VaultError> {
|
||||
Ok(Some(ssh_key("k")))
|
||||
}
|
||||
async fn inherits_from_parent(&self, _s: &str, _f: &str) -> Result<bool, VaultError> {
|
||||
async fn takes_parent_defaults(&self, _s: &str, _f: &str) -> Result<bool, VaultError> {
|
||||
Err(VaultError::Forbidden)
|
||||
}
|
||||
}
|
||||
@@ -2719,6 +2733,35 @@ mod tests {
|
||||
assert!(d.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_defaults_reach_only_the_parents_audience() {
|
||||
// Folder access is a union: a subfolder that inherits and also grants
|
||||
// someone of its own admits people the parent doesn't. corp/partner
|
||||
// inherits from corp but adds a contractor, so corp's default key
|
||||
// must not flow into it, or the contractor connects with it.
|
||||
let cfg = |inherit: bool, groups: &[&str], users: &[&str]| -> FolderConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"allowed_groups": groups,
|
||||
"allowed_users": users,
|
||||
"inherit_from_parent": inherit,
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
assert!(cfg(true, &[], &[]).shares_parent_audience());
|
||||
assert!(
|
||||
!cfg(true, &[], &["contractor@ext.example"]).shares_parent_audience(),
|
||||
"an extra user is an extra audience"
|
||||
);
|
||||
assert!(
|
||||
!cfg(true, &["partners"], &[]).shares_parent_audience(),
|
||||
"an extra group is an extra audience"
|
||||
);
|
||||
assert!(
|
||||
!cfg(false, &[], &[]).shares_parent_audience(),
|
||||
"an isolated folder takes nothing from its parent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_info_marks_inherited_credentials() {
|
||||
let mut defaults = FolderDefaults::new();
|
||||
@@ -2748,6 +2791,17 @@ mod tests {
|
||||
info.apply_inherited(&e, Some(&defaults));
|
||||
assert!(info.has_credentials && !info.inherited_credentials);
|
||||
|
||||
// set to prompt: connecting prompts, so it is not labelled as using
|
||||
// the folder's credentials
|
||||
let e = AddressBookEntry {
|
||||
session_type: "ssh".into(),
|
||||
prompt_credentials: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let mut info = EntryInfo::from(("box", &e));
|
||||
info.apply_inherited(&e, Some(&defaults));
|
||||
assert!(!info.has_credentials && !info.inherited_credentials);
|
||||
|
||||
// the JSON omits the flag when false
|
||||
let v = serde_json::to_value(&info).unwrap();
|
||||
assert!(v.get("inherited_credentials").is_none());
|
||||
|
||||
@@ -738,7 +738,7 @@
|
||||
<div class="field-hint">When ticked, if this folder's own groups list doesn't grant a user access, the check walks up to the parent (and further ancestors if they also inherit). Uncheck to enforce this folder's groups list on its own. Top-level folders have no parent so this has no effect.</div>
|
||||
<details class="fm-defaults" id="fm-defaults">
|
||||
<summary>Default credentials</summary>
|
||||
<div class="field-hint">Lent to entries in this folder that have no password or key of their own, per session type, so an entry created without secrets (a NetBox webhook, an import) connects straight away. An entry's own credentials always win; subfolders inherit per type, but not past a folder with "Inherit permissions from parent folder" unticked. A web entry's default password is typed into the page, where the user can read it. Secrets are write-only: leave a field blank to keep what is stored, tick clear to drop a type.</div>
|
||||
<div class="field-hint">Lent to entries in this folder that have no password or key of their own, per session type, so an entry created without secrets (a NetBox webhook, an import) connects straight away. An entry's own credentials always win; subfolders inherit per type, but only those that inherit permissions from this folder and share it with nobody extra. A web entry's default password is typed into the page, where the user can read it. Secrets are write-only: leave a field blank to keep what is stored, tick clear to drop a type.</div>
|
||||
<table class="fm-defaults-table">
|
||||
<tbody id="fm-defaults-rows"></tbody>
|
||||
</table>
|
||||
@@ -3466,6 +3466,8 @@
|
||||
'<td><input type="password" class="fmd-pass" autocomplete="new-password" placeholder="' + (d && d.has_password ? '(stored)' : 'password') + '"></td>' +
|
||||
'<td>' + (t === 'ssh'
|
||||
? '<textarea class="fmd-key" rows="1" spellcheck="false" placeholder="' + (d && d.has_private_key ? '(stored)' : 'private key') + '"></textarea>'
|
||||
: t === 'rdp'
|
||||
? '<input class="fmd-domain" placeholder="domain" autocomplete="off" value="' + escapeAttr((d && d.domain) || '') + '">'
|
||||
: '') + '</td>' +
|
||||
'<td><label class="fmd-clear-label"><input type="checkbox" class="fmd-clear"> clear</label></td></tr>');
|
||||
});
|
||||
@@ -3486,6 +3488,8 @@
|
||||
var part = {};
|
||||
var user = tr.querySelector('.fmd-user').value.trim();
|
||||
if (user !== ((d && d.username) || '')) part.username = user;
|
||||
var domEl = tr.querySelector('.fmd-domain');
|
||||
if (domEl && domEl.value.trim() !== ((d && d.domain) || '')) part.domain = domEl.value.trim();
|
||||
var pass = tr.querySelector('.fmd-pass').value;
|
||||
if (pass) part.password = pass;
|
||||
var keyEl = tr.querySelector('.fmd-key');
|
||||
|
||||
Reference in New Issue
Block a user