Skip to content

Commit 9a7deb9

Browse files
fix(auth): address PR review feedback on workspace authorization
- Docker e2e: add --health-port and switch readiness probe from `openshell status` to `curl /healthz`, fixing a false-positive readiness check in OIDC mode where the CLI exited 0 without actually contacting the gateway - ListWorkspaces: move membership filtering from post-query N+1 lookups into a SQL EXISTS subquery so pagination applies to the visible set, not the global ordering. Add generic list_with_membership to the persistence layer. - Descriptor validator: reject role/scope fields on unauthenticated and sandbox auth modes, and allow-list workspace_role as user/admin and global_role as platform_admin to catch typos at startup Signed-off-by: Derek Carr <decarr@redhat.com>
1 parent 8ed02ac commit 9a7deb9

6 files changed

Lines changed: 245 additions & 41 deletions

File tree

‎crates/openshell-server/src/auth/descriptor_authz.rs‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ impl DescriptorAuthTable {
150150
}
151151
}
152152

153+
const VALID_WORKSPACE_ROLES: &[&str] = &["user", "admin"];
154+
const VALID_GLOBAL_ROLES: &[&str] = &["platform_admin"];
155+
153156
fn validate_entry(
154157
path: &str,
155158
auth_mode: AuthMode,
@@ -163,7 +166,27 @@ fn validate_entry(
163166
));
164167
}
165168

169+
if let Some(wr) = workspace_role
170+
&& !VALID_WORKSPACE_ROLES.contains(&wr)
171+
{
172+
return Err(format!(
173+
"method {path}: unknown workspace_role '{wr}' (expected one of {VALID_WORKSPACE_ROLES:?})"
174+
));
175+
}
176+
if let Some(gr) = global_role
177+
&& !VALID_GLOBAL_ROLES.contains(&gr)
178+
{
179+
return Err(format!(
180+
"method {path}: unknown global_role '{gr}' (expected one of {VALID_GLOBAL_ROLES:?})"
181+
));
182+
}
183+
166184
if !matches!(auth_mode, AuthMode::Bearer | AuthMode::Dual) {
185+
if workspace_role.is_some() || global_role.is_some() || scope.is_some() {
186+
return Err(format!(
187+
"method {path}: {auth_mode:?} method must not declare role or scope"
188+
));
189+
}
167190
return Ok(());
168191
}
169192

@@ -317,4 +340,60 @@ mod tests {
317340
.expect_err("multiple authorization layers must be rejected");
318341
assert!(err.ends_with("workspace_role and global_role are mutually exclusive"));
319342
}
343+
344+
#[test]
345+
fn non_bearer_methods_reject_role_and_scope_fields() {
346+
let err = validate_entry(
347+
FUTURE_RPC,
348+
AuthMode::Unauthenticated,
349+
Some("user"),
350+
None,
351+
None,
352+
)
353+
.expect_err("unauthenticated with role must be rejected");
354+
assert!(err.contains("must not declare role or scope"));
355+
356+
let err = validate_entry(
357+
FUTURE_RPC,
358+
AuthMode::Sandbox,
359+
None,
360+
Some("platform_admin"),
361+
None,
362+
)
363+
.expect_err("sandbox with global_role must be rejected");
364+
assert!(err.contains("must not declare role or scope"));
365+
366+
let err = validate_entry(
367+
FUTURE_RPC,
368+
AuthMode::Sandbox,
369+
None,
370+
None,
371+
Some("sandbox:read"),
372+
)
373+
.expect_err("sandbox with scope must be rejected");
374+
assert!(err.contains("must not declare role or scope"));
375+
}
376+
377+
#[test]
378+
fn unknown_role_literals_rejected() {
379+
let err = validate_entry(
380+
FUTURE_RPC,
381+
AuthMode::Bearer,
382+
Some("superuser"),
383+
None,
384+
Some("sandbox:read"),
385+
)
386+
.expect_err("unknown workspace_role must be rejected");
387+
assert!(err.contains("unknown workspace_role 'superuser'"));
388+
389+
let err = validate_entry(
390+
FUTURE_RPC,
391+
AuthMode::Bearer,
392+
None,
393+
Some("root"),
394+
Some("sandbox:read"),
395+
)
396+
.expect_err("unknown global_role must be rejected");
397+
assert!(err.contains("unknown global_role 'root'"));
398+
}
320399
}

‎crates/openshell-server/src/grpc/workspace.rs‎

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -48,38 +48,26 @@ impl ObjectType for WorkspaceMember {
4848
}
4949
}
5050

51-
async fn filter_workspaces_by_membership(
52-
state: &Arc<ServerState>,
53-
principal: &Principal,
54-
workspaces: Vec<Workspace>,
55-
) -> Result<Vec<Workspace>, Status> {
56-
let subject = match principal {
51+
/// Extract the subject that needs membership filtering, or `None` if the
52+
/// principal has unrestricted visibility (platform admin, sandbox caller).
53+
fn membership_filter_subject<'a>(
54+
state: &ServerState,
55+
principal: &'a Principal,
56+
) -> Result<Option<&'a str>, Status> {
57+
match principal {
5758
Principal::User(u) => {
5859
if crate::auth::workspace_authz::is_platform_admin_principal(
5960
&u.identity.roles,
6061
&state.admin_role,
6162
) {
62-
return Ok(workspaces);
63+
Ok(None)
64+
} else {
65+
Ok(Some(&u.identity.subject))
6366
}
64-
&u.identity.subject
65-
}
66-
Principal::Sandbox(_) => return Ok(workspaces),
67-
Principal::Anonymous => return Err(Status::unauthenticated("authentication required")),
68-
};
69-
70-
let mut visible = Vec::new();
71-
for ws in workspaces {
72-
let ws_name = ws.metadata.as_ref().map_or("", |m| m.name.as_str());
73-
let member = state
74-
.store
75-
.get_message_by_name::<WorkspaceMember>(ws_name, subject)
76-
.await
77-
.map_err(|e| Status::internal(format!("membership check failed: {e}")))?;
78-
if member.is_some() {
79-
visible.push(ws);
8067
}
68+
Principal::Sandbox(_) => Ok(None),
69+
Principal::Anonymous => Err(Status::unauthenticated("authentication required")),
8170
}
82-
Ok(visible)
8371
}
8472

8573
fn validate_workspace_name(name: &str) -> Result<(), Status> {
@@ -279,23 +267,36 @@ pub(super) async fn handle_list_workspaces(
279267
let principal = super::extract_principal(&request)?;
280268
let req = request.into_inner();
281269
let limit = clamp_limit(req.limit, 100, MAX_PAGE_SIZE);
270+
let subject = membership_filter_subject(state, &principal)?;
282271

283-
let workspaces: Vec<Workspace> = if req.label_selector.is_empty() {
284-
state
272+
let member_type = WorkspaceMember::object_type();
273+
let workspaces = match subject {
274+
Some(subject) if req.label_selector.is_empty() => state
275+
.store
276+
.list_messages_with_membership::<Workspace>(member_type, subject, limit, req.offset)
277+
.await
278+
.map_err(|e| Status::internal(format!("list workspaces failed: {e}")))?,
279+
Some(subject) => {
280+
let all: Vec<Workspace> = state
281+
.store
282+
.list_messages_with_membership(member_type, subject, u32::MAX, 0)
283+
.await
284+
.map_err(|e| Status::internal(format!("list workspaces failed: {e}")))?;
285+
crate::persistence::filter_by_labels(all, &req.label_selector, limit, req.offset)
286+
.map_err(|e| Status::internal(format!("list workspaces failed: {e}")))?
287+
}
288+
None if req.label_selector.is_empty() => state
285289
.store
286290
.list_messages("", limit, req.offset)
287291
.await
288-
.map_err(|e| Status::internal(format!("list workspaces failed: {e}")))?
289-
} else {
290-
state
292+
.map_err(|e| Status::internal(format!("list workspaces failed: {e}")))?,
293+
None => state
291294
.store
292295
.list_messages_with_selector("", &req.label_selector, limit, req.offset)
293296
.await
294-
.map_err(|e| Status::internal(format!("list workspaces failed: {e}")))?
297+
.map_err(|e| Status::internal(format!("list workspaces failed: {e}")))?,
295298
};
296299

297-
let workspaces = filter_workspaces_by_membership(state, &principal, workspaces).await?;
298-
299300
Ok(Response::new(ListWorkspacesResponse { workspaces }))
300301
}
301302

‎crates/openshell-server/src/persistence/mod.rs‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,25 @@ impl Store {
396396
))
397397
}
398398

399+
/// List objects of `object_type` that have a related `member_type` record
400+
/// whose `name` column matches `member_name` in the same workspace.
401+
pub async fn list_with_membership(
402+
&self,
403+
object_type: &str,
404+
member_type: &str,
405+
member_name: &str,
406+
limit: u32,
407+
offset: u32,
408+
) -> PersistenceResult<Vec<ObjectRecord>> {
409+
store_dispatch!(self.list_with_membership(
410+
object_type,
411+
member_type,
412+
member_name,
413+
limit,
414+
offset
415+
))
416+
}
417+
399418
/// List objects by type across all workspaces with label selector filtering.
400419
pub async fn list_all_with_selector(
401420
&self,
@@ -498,6 +517,24 @@ impl Store {
498517
.collect()
499518
}
500519

520+
/// List and decode objects that have a related membership record, with
521+
/// pagination. See [`Store::list_with_membership`] for details.
522+
pub async fn list_messages_with_membership<
523+
T: Message + Default + ObjectType + SetResourceVersion,
524+
>(
525+
&self,
526+
member_type: &str,
527+
member_name: &str,
528+
limit: u32,
529+
offset: u32,
530+
) -> PersistenceResult<Vec<T>> {
531+
self.list_with_membership(T::object_type(), member_type, member_name, limit, offset)
532+
.await?
533+
.into_iter()
534+
.map(decode_record)
535+
.collect()
536+
}
537+
501538
/// List and decode protobuf messages across all workspaces with label
502539
/// selector filtering, hydrating `resource_version` from the authoritative
503540
/// DB row.
@@ -722,6 +759,27 @@ pub fn parse_label_selector(selector: &str) -> PersistenceResult<HashMap<String,
722759
Ok(labels)
723760
}
724761

762+
/// Filter decoded protobuf messages by label selector, then apply pagination.
763+
pub fn filter_by_labels<T: ObjectLabels>(
764+
items: Vec<T>,
765+
label_selector: &str,
766+
limit: u32,
767+
offset: u32,
768+
) -> PersistenceResult<Vec<T>> {
769+
let required = parse_label_selector(label_selector)?;
770+
Ok(items
771+
.into_iter()
772+
.filter(|item| {
773+
let labels = item.object_labels().unwrap_or_default();
774+
required
775+
.iter()
776+
.all(|(k, v)| labels.get(k).is_some_and(|lv| lv == v))
777+
})
778+
.skip(offset as usize)
779+
.take(limit as usize)
780+
.collect())
781+
}
782+
725783
/// Unconditional write helpers — test-only.
726784
///
727785
/// Production code must use [`Store::put_if`] (with [`WriteCondition`]) or

‎crates/openshell-server/src/persistence/postgres.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,42 @@ LIMIT $2 OFFSET $3
457457
Ok(rows.into_iter().map(row_to_object_record).collect())
458458
}
459459

460+
pub async fn list_with_membership(
461+
&self,
462+
object_type: &str,
463+
member_type: &str,
464+
member_name: &str,
465+
limit: u32,
466+
offset: u32,
467+
) -> PersistenceResult<Vec<ObjectRecord>> {
468+
let rows = sqlx::query(
469+
r"
470+
SELECT w.object_type, w.id, w.name, w.workspace, w.payload,
471+
w.created_at_ms, w.updated_at_ms, w.labels, w.resource_version
472+
FROM objects w
473+
WHERE w.object_type = $1 AND w.workspace = ''
474+
AND EXISTS (
475+
SELECT 1 FROM objects m
476+
WHERE m.object_type = $2
477+
AND m.workspace = w.name
478+
AND m.name = $3
479+
)
480+
ORDER BY w.created_at_ms ASC, w.name ASC
481+
LIMIT $4 OFFSET $5
482+
",
483+
)
484+
.bind(object_type)
485+
.bind(member_type)
486+
.bind(member_name)
487+
.bind(i64::from(limit))
488+
.bind(i64::from(offset))
489+
.fetch_all(&self.pool)
490+
.await
491+
.map_err(|e| map_db_error(&e))?;
492+
493+
Ok(rows.into_iter().map(row_to_object_record).collect())
494+
}
495+
460496
pub async fn list_by_scope(
461497
&self,
462498
object_type: &str,

‎crates/openshell-server/src/persistence/sqlite.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,42 @@ LIMIT ?2 OFFSET ?3
498498
Ok(rows.into_iter().map(row_to_object_record).collect())
499499
}
500500

501+
pub async fn list_with_membership(
502+
&self,
503+
object_type: &str,
504+
member_type: &str,
505+
member_name: &str,
506+
limit: u32,
507+
offset: u32,
508+
) -> PersistenceResult<Vec<ObjectRecord>> {
509+
let rows = sqlx::query(
510+
r#"
511+
SELECT w."object_type", w."id", w."name", w."workspace", w."payload",
512+
w."created_at_ms", w."updated_at_ms", w."labels", w."resource_version"
513+
FROM "objects" w
514+
WHERE w."object_type" = ?1 AND w."workspace" = ''
515+
AND EXISTS (
516+
SELECT 1 FROM "objects" m
517+
WHERE m."object_type" = ?2
518+
AND m."workspace" = w."name"
519+
AND m."name" = ?3
520+
)
521+
ORDER BY w."created_at_ms" ASC, w."name" ASC
522+
LIMIT ?4 OFFSET ?5
523+
"#,
524+
)
525+
.bind(object_type)
526+
.bind(member_type)
527+
.bind(member_name)
528+
.bind(i64::from(limit))
529+
.bind(i64::from(offset))
530+
.fetch_all(&self.pool)
531+
.await
532+
.map_err(|e| map_db_error(&e))?;
533+
534+
Ok(rows.into_iter().map(row_to_object_record).collect())
535+
}
536+
501537
pub async fn list_by_scope(
502538
&self,
503539
object_type: &str,

0 commit comments

Comments
 (0)