Skip to content

Commit fe6b7a4

Browse files
committed
feat: Add few apis and tests
- expose project creation - expose granting user/project/role - extend api tests to create project and user for verifying role grant
1 parent e17193f commit fe6b7a4

File tree

46 files changed

+1862
-213
lines changed

Some content is hidden

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

46 files changed

+1862
-213
lines changed

doc/src/developer.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,12 @@ the how the K8 is being deployed and access the Keystone may be directly
3939
accessible from the localhost when i.e. the routes are added in the `/etc/hosts`
4040
file. The manifests are not currently designed to be used for production
4141
deployment.
42+
43+
With the keystone deployed in the Kubernetes running API tests can be performed
44+
with the following command:
45+
46+
```console
47+
48+
KEYSTONE_URL=http://keystone-rs.local cargo nextest run --test api
49+
50+
```

policy/assignment/common.rego

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package identity.assignment
2+
3+
import data.identity
4+
5+
# current domain scope matches the domain_id of the project, or the user and of
6+
# the role (or it is a global role)
7+
project_user_role_domain_matches if {
8+
input.target.project.domain_id != null
9+
input.target.user.domain_id != null
10+
input.credentials.domain_id == input.target.user.domain_id
11+
input.credentials.domain_id == input.target.project.domain_id
12+
identity.own_role_or_global_role
13+
}
14+
15+
# Ensure that the domain_id of the target project is matching the current
16+
# domain scope and the role belongs to the same domain or is global.
17+
project_role_domain_matches if {
18+
input.target.project.domain_id != null
19+
input.credentials.domain_id == input.target.project.domain_id
20+
identity.own_role_or_global_role
21+
}

policy/project/create.rego

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package identity.project.create
2+
3+
import data.identity
4+
5+
# Create a new project
6+
7+
default allow := false
8+
9+
allow if {
10+
"admin" in input.credentials.roles
11+
}
12+
13+
project_domain_matches_domain_scope if {
14+
input.target.project.domain_id != null
15+
input.target.project.domain_id = input.credentials.domain_id
16+
}
17+
18+
allow if {
19+
"manager" in input.credentials.roles
20+
project_domain_matches_domain_scope
21+
}
22+
23+
violation contains {"field": "domain_id", "msg": "creating a new project requires a manager role in the domain scope for the domain where the project is being created."} if {
24+
not "admin" in input.credentials.roles
25+
"manager" in input.credentials.roles
26+
not project_domain_matches_domain_scope
27+
}
28+
29+
violation contains {"field": "domain_id", "msg": "creating a new project requires a manager role in the domain scope for the domain where the project is being created."} if {
30+
not "admin" in input.credentials.roles
31+
not "manager" in input.credentials.roles
32+
project_domain_matches_domain_scope
33+
}

policy/project/create_test.rego

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package test_project_create
2+
3+
import data.identity.project.create
4+
5+
test_allowed if {
6+
create.allow with input as {"credentials": {"roles": ["admin"]}}
7+
create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo"}}}
8+
}
9+
10+
test_forbidden if {
11+
not create.allow with input as {"credentials": {"roles": []}}
12+
not create.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo1"}}}
13+
not create.allow with input as {"credentials": {"roles": ["manager"]}, "target": {"project": {"domain_id": "foo"}}}
14+
}

policy/project/user/role/check.rego

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package identity.project.user.role.check
22

33
import data.identity
4+
import data.identity.assignment
45

56
# Check whether the user has a role assigned on the project.
67

@@ -17,13 +18,9 @@ allow if {
1718

1819
allow if {
1920
"reader" in input.credentials.roles
20-
input.target.project.domain_id != null
21-
input.target.user.domain_id != null
22-
input.credentials.domain_id == input.target.user.domain_id
23-
input.credentials.domain_id == input.target.project.domain_id
24-
identity.own_role_or_global_role
21+
assignment.project_user_role_domain_matches
2522
}
2623

27-
# violation contains {"field": "domain_id", "msg": "checking project-user-role assignment requires domain scope."} if {
28-
# not "admin" in input.credentials.roles
29-
# }
24+
violation contains {"field": "domain_id", "msg": "checking project-user-role assignment requires domain scope matching the domain of all targets."} if {
25+
not assignment.project_user_role_domain_matches
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package identity.project.user.role.grant
2+
3+
import data.identity
4+
import data.identity.assignment
5+
6+
# Grant user a role on a project
7+
8+
default allow := false
9+
10+
allow if {
11+
"admin" in input.credentials.roles
12+
}
13+
14+
allow if {
15+
"manager" in input.credentials.roles
16+
assignment.project_role_domain_matches
17+
}
18+
19+
violation contains {"field": "domain_id", "msg": "granting a role to a user on a project requires admin or manager role in the domain scope."} if {
20+
not "admin" in input.credentials.roles
21+
not "manager" in input.credentials.roles
22+
}
23+
24+
violation contains {"field": "domain_id", "msg": "granting a role to a user on a project requires domain scope matching the domain_id of the target project and role (or a global role)."} if {
25+
assignment.project_role_domain_matches
26+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package test_project_user_role_grant
2+
3+
import data.identity.project.user.role.grant
4+
5+
test_allowed if {
6+
grant.allow with input as {"credentials": {"roles": ["admin"]}}
7+
grant.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": null}}}
8+
grant.allow with input as {"credentials": {"roles": ["manager"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}}
9+
}
10+
11+
test_forbidden if {
12+
not grant.allow with input as {"credentials": {"roles": []}}
13+
not grant.allow with input as {"credentials": {"roles": ["reader"], "system": "foo"}}
14+
not grant.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}}
15+
not grant.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}}
16+
not grant.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}}
17+
not grant.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}}
18+
not grant.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}}
19+
not grant.allow with input as {"credentials": {"roles": ["member"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}, "user": {"domain_id": "foo"}}}
20+
}

src/api/mod.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,66 @@ async fn version(
119119
};
120120
Ok(res)
121121
}
122+
123+
#[cfg(test)]
124+
mod tests {
125+
use sea_orm::DatabaseConnection;
126+
use std::sync::Arc;
127+
128+
use crate::config::Config;
129+
use crate::keystone::{Service, ServiceState};
130+
use crate::policy::{MockPolicy, MockPolicyFactory, PolicyError, PolicyEvaluationResult};
131+
use crate::provider::ProviderBuilder;
132+
use crate::token::{MockTokenProvider, Token, UnscopedPayload};
133+
134+
pub fn get_mocked_state(
135+
provider_builder: ProviderBuilder,
136+
policy_allowed: bool,
137+
) -> ServiceState {
138+
let mut token_mock = MockTokenProvider::default();
139+
token_mock.expect_validate_token().returning(|_, _, _, _| {
140+
Ok(Token::Unscoped(UnscopedPayload {
141+
user_id: "bar".into(),
142+
..Default::default()
143+
}))
144+
});
145+
token_mock
146+
.expect_expand_token_information()
147+
.returning(|_, _| {
148+
Ok(Token::Unscoped(UnscopedPayload {
149+
user_id: "bar".into(),
150+
..Default::default()
151+
}))
152+
});
153+
154+
let provider = provider_builder.token(token_mock).build().unwrap();
155+
156+
let mut policy_factory_mock = MockPolicyFactory::default();
157+
if policy_allowed {
158+
policy_factory_mock.expect_instantiate().returning(move || {
159+
let mut policy_mock = MockPolicy::default();
160+
policy_mock
161+
.expect_enforce()
162+
.returning(|_, _, _, _| Ok(PolicyEvaluationResult::allowed()));
163+
Ok(policy_mock)
164+
});
165+
} else {
166+
policy_factory_mock.expect_instantiate().returning(|| {
167+
let mut policy_mock = MockPolicy::default();
168+
policy_mock.expect_enforce().returning(|_, _, _, _| {
169+
Err(PolicyError::Forbidden(PolicyEvaluationResult::forbidden()))
170+
});
171+
Ok(policy_mock)
172+
});
173+
}
174+
Arc::new(
175+
Service::new(
176+
Config::default(),
177+
DatabaseConnection::Disconnected,
178+
provider,
179+
policy_factory_mock,
180+
)
181+
.unwrap(),
182+
)
183+
}
184+
}

src/api/v3/auth/project/list.rs

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use mockall_double::double;
1818
use serde_json::Value;
1919
use std::collections::HashSet;
2020

21-
use crate::api::v3::project::types::{ProjectShort, ProjectShortList};
21+
use crate::api::v3::project::types::ProjectShortList;
2222
use crate::api::{auth::Auth, error::KeystoneApiError};
2323
use crate::assignment::{
2424
AssignmentApi,
@@ -76,22 +76,26 @@ pub(super) async fn list(
7676
.map(|assignment| assignment.target_id.clone())
7777
.collect();
7878

79-
let projects: Vec<ProjectShort> = state
80-
.provider
81-
.get_resource_provider()
82-
.list_projects(
83-
&state,
84-
&ProjectListParameters {
85-
ids: Some(project_ids),
86-
..Default::default()
87-
},
88-
)
89-
.await?
90-
.into_iter()
91-
.map(Into::into)
92-
.collect();
93-
94-
Ok(ProjectShortList { projects })
79+
Ok(ProjectShortList {
80+
projects: if !project_ids.is_empty() {
81+
state
82+
.provider
83+
.get_resource_provider()
84+
.list_projects(
85+
&state,
86+
&ProjectListParameters {
87+
ids: Some(project_ids),
88+
..Default::default()
89+
},
90+
)
91+
.await?
92+
.into_iter()
93+
.map(Into::into)
94+
.collect()
95+
} else {
96+
vec![]
97+
},
98+
})
9599
}
96100

97101
#[cfg(test)]
@@ -107,6 +111,7 @@ mod tests {
107111
use tower::ServiceExt; // for `call`, `oneshot`, and `ready`
108112
use tower_http::trace::TraceLayer;
109113

114+
use crate::api::v3::project::types::ProjectShort;
110115
use crate::assignment::{MockAssignmentProvider, types::*};
111116
use crate::config::Config;
112117
use crate::keystone::{Service, ServiceState};
@@ -232,6 +237,7 @@ mod tests {
232237
id: "p1".into(),
233238
name: "p1_name".into(),
234239
parent_id: None,
240+
is_domain: false,
235241
},
236242
ProviderProject {
237243
description: None,
@@ -241,6 +247,7 @@ mod tests {
241247
id: "p2".into(),
242248
name: "p2_name".into(),
243249
parent_id: None,
250+
is_domain: false,
244251
},
245252
])
246253
});

src/api/v3/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ use crate::api::types::*;
3636

3737
/// OpenApi specification for v3.
3838
#[derive(OpenApi)]
39-
#[openapi()]
39+
#[openapi(
40+
nest(
41+
(path = "/roles", api = role::ApiDoc),
42+
),
43+
)]
4044
pub struct ApiDoc;
4145

4246
pub(super) fn openapi_router() -> OpenApiRouter<ServiceState> {

0 commit comments

Comments
 (0)