Skip to content

Commit 31c749a

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 31c749a

6 files changed

Lines changed: 234 additions & 41 deletions

File tree

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

Lines changed: 68 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+
if !VALID_WORKSPACE_ROLES.contains(&wr) {
171+
return Err(format!(
172+
"method {path}: unknown workspace_role '{wr}' (expected one of {VALID_WORKSPACE_ROLES:?})"
173+
));
174+
}
175+
}
176+
if let Some(gr) = global_role {
177+
if !VALID_GLOBAL_ROLES.contains(&gr) {
178+
return Err(format!(
179+
"method {path}: unknown global_role '{gr}' (expected one of {VALID_GLOBAL_ROLES:?})"
180+
));
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,49 @@ 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(FUTURE_RPC, AuthMode::Unauthenticated, Some("user"), None, None)
347+
.expect_err("unauthenticated with role must be rejected");
348+
assert!(err.contains("must not declare role or scope"));
349+
350+
let err =
351+
validate_entry(FUTURE_RPC, AuthMode::Sandbox, None, Some("platform_admin"), None)
352+
.expect_err("sandbox with global_role must be rejected");
353+
assert!(err.contains("must not declare role or scope"));
354+
355+
let err = validate_entry(
356+
FUTURE_RPC,
357+
AuthMode::Sandbox,
358+
None,
359+
None,
360+
Some("sandbox:read"),
361+
)
362+
.expect_err("sandbox with scope must be rejected");
363+
assert!(err.contains("must not declare role or scope"));
364+
}
365+
366+
#[test]
367+
fn unknown_role_literals_rejected() {
368+
let err = validate_entry(
369+
FUTURE_RPC,
370+
AuthMode::Bearer,
371+
Some("superuser"),
372+
None,
373+
Some("sandbox:read"),
374+
)
375+
.expect_err("unknown workspace_role must be rejected");
376+
assert!(err.contains("unknown workspace_role 'superuser'"));
377+
378+
let err = validate_entry(
379+
FUTURE_RPC,
380+
AuthMode::Bearer,
381+
None,
382+
Some("root"),
383+
Some("sandbox:read"),
384+
)
385+
.expect_err("unknown global_role must be rejected");
386+
assert!(err.contains("unknown global_role 'root'"));
387+
}
320388
}

‎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,

‎e2e/with-docker-gateway.sh‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ e2e_generate_pki "${GATEWAY_BIN}" "${PKI_DIR}"
445445
export OPENSHELL_E2E_GATEWAY_CA_CERT="${PKI_DIR}/ca.crt"
446446

447447
HOST_PORT=$(e2e_pick_port)
448+
HEALTH_PORT=$(e2e_pick_port)
448449
STATE_DIR="${XDG_STATE_HOME}"
449450
mkdir -p "${STATE_DIR}"
450451
JWT_DIR="${STATE_DIR}/jwt"
@@ -513,6 +514,7 @@ GATEWAY_ARGS=(
513514
--config "${GATEWAY_CONFIG}"
514515
--bind-address 0.0.0.0
515516
--port "${HOST_PORT}"
517+
--health-port "${HEALTH_PORT}"
516518
--drivers docker
517519
--tls-cert "${PKI_DIR}/server/tls.crt"
518520
--tls-key "${PKI_DIR}/server/tls.key"
@@ -567,13 +569,12 @@ fi
567569
echo "Waiting for gateway to become healthy..."
568570
elapsed=0
569571
timeout=120
570-
last_status_output=""
571572
while [ "${elapsed}" -lt "${timeout}" ]; do
572573
if ! kill -0 "${GATEWAY_PID}" 2>/dev/null; then
573574
echo "ERROR: openshell-gateway exited before becoming healthy"
574575
exit 1
575576
fi
576-
if last_status_output="$("${CLI_BIN}" status 2>&1)"; then
577+
if curl -sf "http://127.0.0.1:${HEALTH_PORT}/healthz" >/dev/null 2>&1; then
577578
echo "Gateway healthy after ${elapsed}s."
578579
break
579580
fi
@@ -582,13 +583,6 @@ while [ "${elapsed}" -lt "${timeout}" ]; do
582583
done
583584
if [ "${elapsed}" -ge "${timeout}" ]; then
584585
echo "ERROR: gateway did not become healthy within ${timeout}s"
585-
echo "=== last openshell status output ==="
586-
if [ -n "${last_status_output}" ]; then
587-
printf '%s\n' "${last_status_output}"
588-
else
589-
echo "<no output>"
590-
fi
591-
echo "=== end openshell status output ==="
592586
exit 1
593587
fi
594588

0 commit comments

Comments
 (0)