Skip to content

Commit 688d3ce

Browse files
committed
feat(resource): Add immutable option for project, domain, and role
Python Keystone's resource_options framework registers an `immutable` option (id 0000) on project, domain, and role, blocking update/delete unless a request explicitly clears it. This was previously unwired here: the project_option/role_option DB tables existed but nothing above the entity layer used them. Adds ProjectOptions/RoleOptions core types, persists them through the existing tables, exposes `options.immutable` on the v3 project, domain, and role APIs, and enforces the block in the resource/role services before update or delete, surfacing a 403 Forbidden. Signed-off-by: Claude <noreply@anthropic.com>
1 parent 0644b9d commit 688d3ce

62 files changed

Lines changed: 1718 additions & 79 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

crates/api-types/src/error_conv.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ impl From<RoleProviderError> for KeystoneApiError {
204204
},
205205
ref err @ RoleProviderError::Conflict(..) => Self::Conflict(err.to_string()),
206206
ref err @ RoleProviderError::Validation { .. } => Self::BadRequest(err.to_string()),
207+
err @ RoleProviderError::Immutable(..) => Self::forbidden(err),
207208
other => Self::InternalError(other.to_string()),
208209
}
209210
}
@@ -316,6 +317,7 @@ impl From<ResourceProviderError> for KeystoneApiError {
316317
identifier: x,
317318
},
318319
ResourceProviderError::InvalidProjectDomain(x) => Self::BadRequest(x),
320+
err @ ResourceProviderError::Immutable(..) => Self::forbidden(err),
319321
other => Self::InternalError(other.to_string()),
320322
}
321323
}

crates/api-types/src/v3/domain.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize};
1818
use validator::Validate;
1919

2020
use crate::Link;
21+
pub use crate::v3::project::ProjectOptions;
2122

2223
/// Short domain representation.
2324
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@@ -79,6 +80,14 @@ pub struct Domain {
7980
/// The domain name.
8081
#[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))]
8182
pub name: String,
83+
84+
/// The resource options for the domain. A domain is a project with
85+
/// `is_domain = true` and shares its resource options, hence the shared
86+
/// `ProjectOptions` type.
87+
#[cfg_attr(feature = "builder", builder(default))]
88+
#[serde(skip_serializing_if = "Option::is_none")]
89+
#[cfg_attr(feature = "validate", validate(nested))]
90+
pub options: Option<ProjectOptions>,
8291
}
8392

8493
/// New domain data.
@@ -120,6 +129,14 @@ pub struct DomainCreate {
120129
/// The domain name.
121130
#[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))]
122131
pub name: String,
132+
133+
/// The resource options for the domain. A domain is a project with
134+
/// `is_domain = true` and shares its resource options, hence the shared
135+
/// `ProjectOptions` type.
136+
#[cfg_attr(feature = "builder", builder(default))]
137+
#[serde(skip_serializing_if = "Option::is_none")]
138+
#[cfg_attr(feature = "validate", validate(nested))]
139+
pub options: Option<ProjectOptions>,
123140
}
124141

125142
/// Complete response with the domain data.
@@ -174,6 +191,14 @@ pub struct DomainUpdate {
174191
#[cfg_attr(feature = "builder", builder(default))]
175192
#[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))]
176193
pub name: Option<String>,
194+
195+
/// The resource options for the domain. A domain is a project with
196+
/// `is_domain = true` and shares its resource options, hence the shared
197+
/// `ProjectOptions` type.
198+
#[cfg_attr(feature = "builder", builder(default))]
199+
#[serde(skip_serializing_if = "Option::is_none")]
200+
#[cfg_attr(feature = "validate", validate(nested))]
201+
pub options: Option<ProjectOptions>,
177202
}
178203

179204
/// Domain update request.

crates/api-types/src/v3/domain_conv.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,14 @@ impl From<&provider_types::Domain> for api_types::DomainShort {
4141

4242
impl From<provider_types::Domain> for api_types::Domain {
4343
fn from(value: provider_types::Domain) -> Self {
44+
let opts: api_types::ProjectOptions = value.options.into();
4445
Self {
4546
description: value.description,
4647
enabled: value.enabled,
4748
extra: value.extra,
4849
id: value.id,
4950
name: value.name,
51+
options: opts.immutable.is_some().then_some(opts),
5052
}
5153
}
5254
}
@@ -59,6 +61,7 @@ impl From<api_types::DomainCreate> for provider_types::DomainCreate {
5961
extra: value.extra,
6062
id: value.id,
6163
name: value.name,
64+
options: value.options.map(Into::into),
6265
}
6366
}
6467
}
@@ -71,6 +74,7 @@ impl From<api_types::DomainUpdateRequest> for provider_types::DomainUpdate {
7174
enabled: domain.enabled,
7275
extra: domain.extra,
7376
name: domain.name,
77+
options: domain.options.map(Into::into),
7478
}
7579
}
7680
}

crates/api-types/src/v3/project.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,40 @@ pub struct Project {
9898
#[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))]
9999
pub name: String,
100100

101+
/// The resource options for the project. Available resource options are
102+
/// documented in `ProjectOptions`.
103+
#[cfg_attr(feature = "builder", builder(default))]
104+
#[serde(skip_serializing_if = "Option::is_none")]
105+
#[cfg_attr(feature = "validate", validate(nested))]
106+
pub options: Option<ProjectOptions>,
107+
101108
/// The ID of the parent for the project.
102109
#[serde(skip_serializing_if = "Option::is_none")]
103110
#[cfg_attr(feature = "validate", validate(length(min = 1, max = 64)))]
104111
pub parent_id: Option<String>,
105112
}
106113

114+
/// Project resource options.
115+
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
116+
#[cfg_attr(
117+
feature = "builder",
118+
derive(derive_builder::Builder),
119+
builder(
120+
build_fn(error = "crate::error::BuilderError"),
121+
setter(strip_option, into)
122+
)
123+
)]
124+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
125+
#[cfg_attr(feature = "validate", derive(validator::Validate))]
126+
pub struct ProjectOptions {
127+
/// When `true`, the project cannot be updated or deleted until an
128+
/// administrator explicitly sets this back to `false`. Also used for
129+
/// domains, which persist through the same underlying project.
130+
#[cfg_attr(feature = "builder", builder(default))]
131+
#[serde(skip_serializing_if = "Option::is_none")]
132+
pub immutable: Option<bool>,
133+
}
134+
107135
/// New project data.
108136
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
109137
#[cfg_attr(
@@ -160,7 +188,13 @@ pub struct ProjectCreate {
160188
#[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))]
161189
pub name: String,
162190

163-
// TODO: add options
191+
/// The resource options for the project. Available resource options are
192+
/// documented in `ProjectOptions`.
193+
#[cfg_attr(feature = "builder", builder(default))]
194+
#[serde(skip_serializing_if = "Option::is_none")]
195+
#[cfg_attr(feature = "validate", validate(nested))]
196+
pub options: Option<ProjectOptions>,
197+
164198
/// The ID of the parent of the project.
165199
///
166200
/// If specified on project creation, this places the project within a
@@ -246,6 +280,13 @@ pub struct ProjectUpdate {
246280
#[cfg_attr(feature = "builder", builder(default))]
247281
#[cfg_attr(feature = "validate", validate(length(min = 1, max = 255)))]
248282
pub name: Option<String>,
283+
284+
/// The resource options for the project. Available resource options are
285+
/// documented in `ProjectOptions`.
286+
#[cfg_attr(feature = "builder", builder(default))]
287+
#[serde(skip_serializing_if = "Option::is_none")]
288+
#[cfg_attr(feature = "validate", validate(nested))]
289+
pub options: Option<ProjectOptions>,
249290
}
250291

251292
/// Project update request.

crates/api-types/src/v3/project_conv.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ use openstack_keystone_core_types::resource as provider_types;
1919

2020
use crate::v3::project as api_types;
2121

22+
impl From<provider_types::ProjectOptions> for api_types::ProjectOptions {
23+
fn from(value: provider_types::ProjectOptions) -> Self {
24+
Self {
25+
immutable: value.immutable,
26+
}
27+
}
28+
}
29+
30+
impl From<api_types::ProjectOptions> for provider_types::ProjectOptions {
31+
fn from(value: api_types::ProjectOptions) -> Self {
32+
Self {
33+
immutable: value.immutable,
34+
}
35+
}
36+
}
37+
2238
impl From<provider_types::Project> for api_types::ProjectShort {
2339
fn from(value: provider_types::Project) -> Self {
2440
Self {
@@ -43,6 +59,7 @@ impl From<&provider_types::Project> for api_types::ProjectShort {
4359

4460
impl From<provider_types::Project> for api_types::Project {
4561
fn from(value: provider_types::Project) -> Self {
62+
let opts: api_types::ProjectOptions = value.options.into();
4663
Self {
4764
description: value.description,
4865
domain_id: value.domain_id,
@@ -51,6 +68,7 @@ impl From<provider_types::Project> for api_types::Project {
5168
id: value.id,
5269
is_domain: value.is_domain,
5370
name: value.name,
71+
options: opts.immutable.is_some().then_some(opts),
5472
parent_id: value.parent_id,
5573
}
5674
}
@@ -66,6 +84,7 @@ impl From<api_types::ProjectCreate> for provider_types::ProjectCreate {
6684
id: None,
6785
is_domain: value.is_domain,
6886
name: value.name,
87+
options: value.options.map(Into::into),
6988
parent_id: value.parent_id,
7089
}
7190
}
@@ -79,6 +98,7 @@ impl From<api_types::ProjectUpdateRequest> for provider_types::ProjectUpdate {
7998
enabled: project.enabled,
8099
extra: project.extra,
81100
name: project.name,
101+
options: project.options.map(Into::into),
82102
}
83103
}
84104
}

crates/api-types/src/v3/role.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,32 @@ pub struct Role {
4444
#[cfg_attr(feature = "openapi", schema(inline, additional_properties))]
4545
#[serde(flatten)]
4646
pub extra: HashMap<String, Value>,
47+
48+
/// The resource options for the role. Available resource options are
49+
/// documented in `RoleOptions`.
50+
#[serde(skip_serializing_if = "Option::is_none")]
51+
#[cfg_attr(feature = "validate", validate(nested))]
52+
pub options: Option<RoleOptions>,
53+
}
54+
55+
/// Role resource options.
56+
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
57+
#[cfg_attr(
58+
feature = "builder",
59+
derive(derive_builder::Builder),
60+
builder(
61+
build_fn(error = "crate::error::BuilderError"),
62+
setter(strip_option, into)
63+
)
64+
)]
65+
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66+
#[cfg_attr(feature = "validate", derive(validator::Validate))]
67+
pub struct RoleOptions {
68+
/// When `true`, the role cannot be updated or deleted until an
69+
/// administrator explicitly sets this back to `false`.
70+
#[cfg_attr(feature = "builder", builder(default))]
71+
#[serde(skip_serializing_if = "Option::is_none")]
72+
pub immutable: Option<bool>,
4773
}
4874

4975
/// The role reference data.
@@ -194,6 +220,13 @@ pub struct RoleCreate {
194220
#[cfg_attr(feature = "openapi", schema(inline, additional_properties))]
195221
#[serde(flatten)]
196222
pub extra: HashMap<String, Value>,
223+
224+
/// The resource options for the role. Available resource options are
225+
/// documented in `RoleOptions`.
226+
#[cfg_attr(feature = "builder", builder(default))]
227+
#[serde(skip_serializing_if = "Option::is_none")]
228+
#[cfg_attr(feature = "validate", validate(nested))]
229+
pub options: Option<RoleOptions>,
197230
}
198231

199232
/// New role creation request.
@@ -234,6 +267,13 @@ pub struct RoleUpdate {
234267
#[cfg_attr(feature = "openapi", schema(inline, additional_properties))]
235268
#[serde(flatten)]
236269
pub extra: HashMap<String, Value>,
270+
271+
/// The resource options for the role. Available resource options are
272+
/// documented in `RoleOptions`.
273+
#[cfg_attr(feature = "builder", builder(default))]
274+
#[serde(skip_serializing_if = "Option::is_none")]
275+
#[cfg_attr(feature = "validate", validate(nested))]
276+
pub options: Option<RoleOptions>,
237277
}
238278

239279
/// Role update request.

crates/api-types/src/v3/role_conv.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,32 @@ use openstack_keystone_core_types::role as provider_types;
1616

1717
use crate::v3::role as api_types;
1818

19+
impl From<provider_types::RoleOptions> for api_types::RoleOptions {
20+
fn from(value: provider_types::RoleOptions) -> Self {
21+
Self {
22+
immutable: value.immutable,
23+
}
24+
}
25+
}
26+
27+
impl From<api_types::RoleOptions> for provider_types::RoleOptions {
28+
fn from(value: api_types::RoleOptions) -> Self {
29+
Self {
30+
immutable: value.immutable,
31+
}
32+
}
33+
}
34+
1935
impl From<provider_types::Role> for api_types::Role {
2036
fn from(value: provider_types::Role) -> Self {
37+
let opts: api_types::RoleOptions = value.options.into();
2138
Self {
2239
id: value.id,
2340
domain_id: value.domain_id,
2441
name: value.name,
2542
description: value.description,
2643
extra: value.extra,
44+
options: opts.immutable.is_some().then_some(opts),
2745
}
2846
}
2947
}
@@ -65,6 +83,7 @@ impl From<api_types::RoleCreateRequest> for provider_types::RoleCreate {
6583
extra: value.role.extra,
6684
id: None,
6785
name: value.role.name,
86+
options: value.role.options.map(Into::into),
6887
}
6988
}
7089
}
@@ -76,6 +95,7 @@ impl From<api_types::RoleUpdateRequest> for provider_types::RoleUpdate {
7695
description: role.description.map(Some),
7796
extra: role.extra,
7897
name: role.name,
98+
options: role.options.map(Into::into),
7999
}
80100
}
81101
}

crates/cli-manage/src/bootstrap.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,7 @@ mod tests {
720720
domain_id: None,
721721
description: None,
722722
extra: HashMap::new(),
723+
options: None,
723724
}
724725
}
725726

@@ -733,6 +734,7 @@ mod tests {
733734
description: None,
734735
is_domain: false,
735736
extra: HashMap::new(),
737+
options: None,
736738
}
737739
}
738740

crates/core-types/src/auth/tests.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ fn make_project() -> Project {
7676
is_domain: false,
7777
parent_id: None,
7878
extra: HashMap::new(),
79+
options: Default::default(),
7980
}
8081
}
8182

@@ -106,6 +107,7 @@ fn make_domain() -> Domain {
106107
enabled: true,
107108
description: None,
108109
extra: HashMap::new(),
110+
options: Default::default(),
109111
}
110112
}
111113

@@ -116,6 +118,7 @@ fn make_disabled_domain() -> Domain {
116118
enabled: false,
117119
description: None,
118120
extra: HashMap::new(),
121+
options: Default::default(),
119122
}
120123
}
121124

@@ -332,6 +335,7 @@ fn make_trust_with_roles(roles: Option<Vec<RoleRef>>) -> SecurityContext {
332335
is_domain: false,
333336
parent_id: None,
334337
extra: HashMap::new(),
338+
options: Default::default(),
335339
},
336340
project_domain: make_domain(),
337341
})),

0 commit comments

Comments
 (0)