Skip to main content

cognee_http_server/dto/
permissions.rs

1//! DTOs for `/api/v1/permissions/*` per `routers/permissions.md §4`.
2//!
3//! Per Decision 10 (camelCase wire convention), this module is a **mixed bag**
4//! because Python's permissions handlers return plain `JSONResponse` dicts —
5//! not `OutDTO` subclasses. Concretely:
6//!
7//! - `SelectTenantDTO` (an `InDTO` in Python) **does** follow Decision 10:
8//!   the wire is camelCase (`tenantId`) with `tenant_id` accepted as an
9//!   inbound alias.
10//! - All response DTOs (`MessageResponse`, `CreateRoleResponse`,
11//!   `CreateTenantResponse`, `SelectTenantResponse`, `TenantSummary`,
12//!   `RoleSummary`, `UserInRole`, `UserInTenant`) emit snake_case because
13//!   their Python counterparts are plain dicts built with literal snake_case
14//!   keys via `JSONResponse(content={...})`. `jsonable_encoder` does not
15//!   synthesize aliases for plain dicts.
16//! - Query-parameter DTOs (`GrantDatasetPermissionQuery`, `CreateRoleQuery`,
17//!   `CreateTenantQuery`, `AssignRoleQuery`, `AddUserToTenantQuery`) keep
18//!   snake_case — FastAPI does not apply `alias_generator` to query params.
19
20use serde::{Deserialize, Serialize};
21use utoipa::ToSchema;
22use uuid::Uuid;
23
24// ── Request DTOs ────────────────────────────────────────────────────────────
25
26/// Body for `POST /tenants/select`. `tenant_id: null` is a meaningful signal
27/// (clear the user's current tenant) — see `routers/permissions.md §2.9`.
28///
29/// Python's `SelectTenantDTO` inherits `InDTO`, so the wire is camelCase
30/// (`tenantId`) per Decision 10. Snake_case `tenant_id` is accepted as an
31/// inbound alias for compatibility with `populate_by_name=True` clients.
32#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
33#[serde(rename_all = "camelCase")]
34pub struct SelectTenantDTO {
35    #[serde(default, alias = "tenant_id")]
36    pub tenant_id: Option<Uuid>,
37}
38
39/// Body for `POST /datasets/{principal_id}` — top-level JSON array of UUIDs.
40/// Modelled as a transparent newtype so the wire format is identical to
41/// Python's `dataset_ids: List[UUID]`.
42#[derive(Debug, Clone, Deserialize, Serialize, ToSchema)]
43#[serde(transparent)]
44pub struct GrantDatasetPermissionBody(pub Vec<Uuid>);
45
46// ── Query-param DTOs ────────────────────────────────────────────────────────
47
48#[derive(Debug, Clone, Deserialize, ToSchema)]
49#[serde(rename_all = "snake_case")]
50pub struct GrantDatasetPermissionQuery {
51    pub permission_name: String,
52}
53
54#[derive(Debug, Clone, Deserialize, ToSchema)]
55#[serde(rename_all = "snake_case")]
56pub struct CreateRoleQuery {
57    pub role_name: String,
58}
59
60#[derive(Debug, Clone, Deserialize, ToSchema)]
61#[serde(rename_all = "snake_case")]
62pub struct CreateTenantQuery {
63    pub tenant_name: String,
64}
65
66#[derive(Debug, Clone, Deserialize, ToSchema)]
67#[serde(rename_all = "snake_case")]
68pub struct AssignRoleQuery {
69    pub role_id: Uuid,
70}
71
72#[derive(Debug, Clone, Deserialize, ToSchema)]
73#[serde(rename_all = "snake_case")]
74pub struct AddUserToTenantQuery {
75    pub tenant_id: Uuid,
76}
77
78/// Query for `DELETE /datasets/{principal_id}` (revoke ACL).
79#[derive(Debug, Clone, Deserialize, ToSchema)]
80#[serde(rename_all = "snake_case")]
81pub struct RevokeDatasetPermissionQuery {
82    /// one of read|write|delete|share
83    pub permission_name: String,
84}
85
86/// Query for `DELETE /users/{user_id}/roles` (remove user from role).
87#[derive(Debug, Clone, Deserialize, ToSchema)]
88#[serde(rename_all = "snake_case")]
89pub struct RemoveUserFromRoleQuery {
90    pub role_id: Uuid,
91}
92
93// ── Response DTOs ───────────────────────────────────────────────────────────
94
95#[derive(Debug, Clone, Serialize, ToSchema)]
96#[serde(rename_all = "snake_case")]
97pub struct MessageResponse {
98    pub message: String,
99}
100
101#[derive(Debug, Clone, Serialize, ToSchema)]
102#[serde(rename_all = "snake_case")]
103pub struct CreateRoleResponse {
104    pub message: String,
105    pub role_id: Uuid,
106    pub tenant_id: Uuid,
107}
108
109#[derive(Debug, Clone, Serialize, ToSchema)]
110#[serde(rename_all = "snake_case")]
111pub struct CreateTenantResponse {
112    pub message: String,
113    pub tenant_id: Uuid,
114}
115
116/// Response for `POST /tenants/select`.
117///
118/// **Python parity**: when the request `tenant_id` is `null`, Python returns
119/// the literal **JSON string `"None"`** (Python's `str(None)`). We replicate
120/// via a custom serializer; default `Option<Uuid>` would emit JSON `null`.
121#[derive(Debug, Clone, Serialize, ToSchema)]
122pub struct SelectTenantResponse {
123    pub message: String,
124    /// Echoes the request value. Serialized as `"None"` (string) when the
125    /// request was `null`, otherwise as the hyphenated UUID string.
126    #[serde(serialize_with = "serialize_tenant_id_with_none_literal")]
127    pub tenant_id: Option<Uuid>,
128}
129
130fn serialize_tenant_id_with_none_literal<S>(
131    value: &Option<Uuid>,
132    serializer: S,
133) -> Result<S::Ok, S::Error>
134where
135    S: serde::Serializer,
136{
137    match value {
138        Some(uuid) => serializer.serialize_str(&uuid.hyphenated().to_string()),
139        None => serializer.serialize_str("None"),
140    }
141}
142
143#[derive(Debug, Clone, Serialize, ToSchema)]
144#[serde(rename_all = "snake_case")]
145pub struct TenantSummary {
146    pub id: Uuid,
147    pub name: String,
148}
149
150#[derive(Debug, Clone, Serialize, ToSchema)]
151#[serde(rename_all = "snake_case")]
152pub struct RoleSummary {
153    pub id: Uuid,
154    pub name: String,
155    /// Always `null` — Python emits `getattr(role, "description", None)` and
156    /// the column does not exist on its model.
157    pub description: Option<String>,
158    /// Number of users assigned to this role. Only populated by
159    /// `GET /tenants/{t}/roles`; the other endpoints emit `null`.
160    pub user_count: Option<usize>,
161}
162
163#[derive(Debug, Clone, Serialize, ToSchema)]
164#[serde(rename_all = "snake_case")]
165pub struct UserInRole {
166    pub id: Uuid,
167    /// Python sets `name = user.email`; match the field name on the wire.
168    pub name: String,
169}
170
171#[derive(Debug, Clone, Serialize, ToSchema)]
172#[serde(rename_all = "snake_case")]
173pub struct UserInTenant {
174    pub id: Uuid,
175    pub email: String,
176    /// Roles scoped to the requested tenant only.
177    pub roles: Vec<RoleSummary>,
178}
179
180#[cfg(test)]
181#[allow(
182    clippy::unwrap_used,
183    clippy::expect_used,
184    reason = "test code — panics are acceptable failures"
185)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn select_tenant_dto_accepts_null_tenant_id() {
191        // Snake-case input retained as alias for Python's `populate_by_name=True`.
192        let parsed: SelectTenantDTO =
193            serde_json::from_str(r#"{"tenant_id": null}"#).expect("parse");
194        assert_eq!(parsed.tenant_id, None);
195    }
196
197    #[test]
198    fn select_tenant_dto_accepts_missing_field() {
199        let parsed: SelectTenantDTO = serde_json::from_str("{}").expect("parse");
200        assert_eq!(parsed.tenant_id, None);
201    }
202
203    #[test]
204    fn select_tenant_dto_accepts_camelcase_input() {
205        let parsed: SelectTenantDTO =
206            serde_json::from_str(r#"{"tenantId": "00000000-0000-0000-0000-000000000001"}"#)
207                .expect("parse camelCase");
208        assert_eq!(
209            parsed.tenant_id,
210            Some(Uuid::parse_str("00000000-0000-0000-0000-000000000001").expect("uuid"))
211        );
212    }
213
214    #[test]
215    fn select_tenant_dto_accepts_snake_case_input_via_alias() {
216        let parsed: SelectTenantDTO =
217            serde_json::from_str(r#"{"tenant_id": "00000000-0000-0000-0000-000000000001"}"#)
218                .expect("parse snake_case");
219        assert_eq!(
220            parsed.tenant_id,
221            Some(Uuid::parse_str("00000000-0000-0000-0000-000000000001").expect("uuid"))
222        );
223    }
224
225    #[test]
226    fn select_tenant_dto_serializes_camelcase_only() {
227        let dto = SelectTenantDTO {
228            tenant_id: Some(Uuid::nil()),
229        };
230        let s = serde_json::to_string(&dto).expect("serialize");
231        assert!(s.contains("\"tenantId\""), "missing tenantId: {s}");
232        assert!(
233            !s.contains("\"tenant_id\""),
234            "snake_case tenant_id leaked: {s}"
235        );
236    }
237
238    #[test]
239    fn select_tenant_response_serializes_none_as_string() {
240        let r = SelectTenantResponse {
241            message: "Tenant selected.".into(),
242            tenant_id: None,
243        };
244        let s = serde_json::to_string(&r).expect("serialize");
245        assert!(s.contains(r#""tenant_id":"None""#));
246    }
247
248    #[test]
249    fn select_tenant_response_serializes_uuid_as_string() {
250        let id = Uuid::nil();
251        let r = SelectTenantResponse {
252            message: "Tenant selected.".into(),
253            tenant_id: Some(id),
254        };
255        let s = serde_json::to_string(&r).expect("serialize");
256        assert!(s.contains("00000000-0000-0000-0000-000000000000"));
257    }
258}