Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions policy/identity.rego
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ global_idp if {
input.target.domain_id == null
}


own_idp if {
input.target.domain_id != null
input.target.domain_id == input.credentials.domain_id
Expand Down Expand Up @@ -49,3 +50,21 @@ own_token_restriction if {
input.target.domain_id != null
input.target.domain_id == input.credentials.domain_id
}

global_role if {
input.target.role.domain_id == null
}

own_role if {
input.target.role.domain_id != null
input.credentials.domain_id == input.target.role.domain_id
}

# Domain role or the global role.
own_role_or_global_role if {
global_role
}

own_role_or_global_role if {
own_role
}
29 changes: 29 additions & 0 deletions policy/project/user/role/check.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package identity.project.user.role.check

import data.identity

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

default allow := false

allow if {
"admin" in input.credentials.roles
}

allow if {
"reader" in input.credentials.roles
input.credentials.system == "all"
}

allow if {
"reader" in input.credentials.roles
input.target.project.domain_id != null
input.target.user.domain_id != null
input.credentials.domain_id == input.target.user.domain_id
input.credentials.domain_id == input.target.project.domain_id
identity.own_role_or_global_role
}

# violation contains {"field": "domain_id", "msg": "checking project-user-role assignment requires domain scope."} if {
# not "admin" in input.credentials.roles
# }
20 changes: 20 additions & 0 deletions policy/project/user/role/check_test.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package test_project_user_role_check

import data.identity.project.user.role.check

test_allowed if {
check.allow with input as {"credentials": {"roles": ["admin"]}}
check.allow with input as {"credentials": {"roles": ["reader"], "system": "all"}}
check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": null}}}
check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}}
}

test_forbidden if {
not check.allow with input as {"credentials": {"roles": []}}
not check.allow with input as {"credentials": {"roles": ["reader"], "system": "foo"}}
not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}}
not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}}
not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}}
not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"user": {"domain_id": "foo1"}, "project": {"domain_id": "foo1"}, "role": {"domain_id": "foo1"}}}
not check.allow with input as {"credentials": {"roles": ["reader"], "domain_id": "foo"}, "target": {"project": {"domain_id": "foo"}, "role": {"domain_id": "foo"}}}
}
4 changes: 3 additions & 1 deletion src/api/v3/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use crate::keystone::ServiceState;

pub mod auth;
pub mod group;
pub mod project;
pub mod role;
pub mod role_assignment;
pub mod user;
Expand All @@ -42,9 +43,10 @@ pub(super) fn openapi_router() -> OpenApiRouter<ServiceState> {
OpenApiRouter::new()
.nest("/auth", auth::openapi_router())
.nest("/groups", group::openapi_router())
.nest("/role_assignments", role_assignment::openapi_router())
.nest("/projects", project::openapi_router())
.nest("/roles", role::openapi_router())
.nest("/users", user::openapi_router())
.merge(role_assignment::openapi_router())
.routes(routes!(version))
}

Expand Down
44 changes: 44 additions & 0 deletions src/api/v3/project/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0
//! # Project API
use utoipa::OpenApi;
use utoipa_axum::router::OpenApiRouter;

use crate::keystone::ServiceState;

/// OpenApi specification for the project API.
#[derive(OpenApi)]
#[openapi(
tags(
(name="projects", description=r#"A project is the base unit of resource ownership. Resources are owned by a specific project. A project is owned by a specific domain.

(Since Identity API v3.4) You can create a hierarchy of projects by setting a parent_id when you create a project. All projects in a hierarchy must be owned by the same domain.

(Since Identity API v3.6) Projects may, in addition to acting as containers for OpenStack resources, act as a domain (by setting the attribute is_domain to true), in which case it provides a namespace in which users, groups and other projects can be created. In fact, a domain created using the POST /domains API will actually be represented as a project with is_domain set to true with no parent (parent_id is null).

Given this, all projects are considered part of a project hierarchy. Projects created in a domain prior to v3.6 are represented as a two-level hierarchy, with a project that has is_domain set to true as the root and all other projects referencing the root as their parent.

A project acting as a domain can potentially also act as a container for OpenStack resources, although this depends on whether the policy rule for the relevant resource creation allows this.

# Note

A project's name must be unique within a domain and no more than 64 characters. A project's name must be able to be sent within valid JSON, which could be any UTF-8 character. However, this is constrained to the given backend where project names are stored. For instance, MySQL's restrictions states that UTF-8 support is constrained to the characters in the Basic Multilingual Plane (BMP). Supplementary characters are not permitted. Note that this last restriction is generally true for all names within resources of the Identity API. Creating a project without using a domain scoped token, i.e. using a project scoped token or a system scoped token, and also without specifying a domain or domain_id, the project will automatically be created on the default domain.
"#),
)
)]
pub struct ApiDoc;

pub(crate) fn openapi_router() -> OpenApiRouter<ServiceState> {
OpenApiRouter::new()
}
183 changes: 183 additions & 0 deletions src/api/v3/role/list.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

use axum::{
extract::{Query, State},
response::IntoResponse,
};

use super::types::{Role, RoleList, RoleListParameters};
use crate::api::auth::Auth;
use crate::api::error::KeystoneApiError;
use crate::assignment::AssignmentApi;
use crate::keystone::ServiceState;

/// List roles
#[utoipa::path(
get,
path = "/",
params(RoleListParameters),
description = "List roles",
responses(
(status = OK, description = "List of roles", body = RoleList),
(status = 500, description = "Internal error", example = json!(KeystoneApiError::InternalError(String::from("id = 1"))))
),
tag="roles"
)]
#[tracing::instrument(name = "api::role_list", level = "debug", skip(state))]
pub(super) async fn list(
Auth(user_auth): Auth,
Query(query): Query<RoleListParameters>,
State(state): State<ServiceState>,
) -> Result<impl IntoResponse, KeystoneApiError> {
let roles: Vec<Role> = state
.provider
.get_assignment_provider()
.list_roles(&state, &query.into())
.await?
.into_iter()
.map(Into::into)
.collect();
Ok(RoleList { roles })
}

#[cfg(test)]
mod tests {
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt; // for `collect`

use serde_json::json;

use tower::ServiceExt; // for `call`, `oneshot`, and `ready`
use tower_http::trace::TraceLayer;

use super::super::openapi_router;
use super::super::tests::get_mocked_state;
use crate::api::v3::role::types::{
Role as ApiRole, //GroupCreate as ApiGroupCreate, GroupCreateRequest,
RoleList,
};
use crate::assignment::{
MockAssignmentProvider,
types::{Role, RoleListParameters},
};

use crate::tests::api::get_mocked_state_unauthed;

#[tokio::test]
async fn test_list() {
let mut assignment_mock = MockAssignmentProvider::default();
assignment_mock
.expect_list_roles()
.withf(|_, _: &RoleListParameters| true)
.returning(|_, _| {
Ok(vec![Role {
id: "1".into(),
name: "2".into(),
..Default::default()
}])
});

let state = get_mocked_state(assignment_mock);

let mut api = openapi_router()
.layer(TraceLayer::new_for_http())
.with_state(state);

let response = api
.as_service()
.oneshot(
Request::builder()
.uri("/")
.header("x-auth-token", "foo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::OK);

let body = response.into_body().collect().await.unwrap().to_bytes();
let res: RoleList = serde_json::from_slice(&body).unwrap();
assert_eq!(
vec![ApiRole {
id: "1".into(),
name: "2".into(),
// for some reason when deserializing missing value appears still as an empty
// object
extra: Some(json!({})),
..Default::default()
}],
res.roles
);
}

#[tokio::test]
async fn test_list_qp() {
let mut assignment_mock = MockAssignmentProvider::default();
assignment_mock
.expect_list_roles()
.withf(|_, qp: &RoleListParameters| {
RoleListParameters {
domain_id: Some("domain".into()),
name: Some("name".into()),
} == *qp
})
.returning(|_, _| Ok(Vec::new()));

let state = get_mocked_state(assignment_mock);

let mut api = openapi_router()
.layer(TraceLayer::new_for_http())
.with_state(state);

let response = api
.as_service()
.oneshot(
Request::builder()
.uri("/?domain_id=domain&name=name")
.header("x-auth-token", "foo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(response.status(), StatusCode::OK);

let body = response.into_body().collect().await.unwrap().to_bytes();
let _res: RoleList = serde_json::from_slice(&body).unwrap();
}

#[tokio::test]
async fn test_list_unauth() {
let state = get_mocked_state_unauthed();

let mut api = openapi_router()
.layer(TraceLayer::new_for_http())
.with_state(state);

let response = api
.as_service()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();

assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}
Loading
Loading