Skip to content

Commit 787778b

Browse files
authored
Refactor device auth schema (#1344)
Device auth request records are now short-lived so that we can use `user_code` as a primary key. Datastore authz is implemented for all but access token lookup, which needs to be looked up both by `token` (primary key) and the unique combination of `client_id` and `device_code`.
1 parent fce72c5 commit 787778b

File tree

11 files changed

+364
-114
lines changed

11 files changed

+364
-114
lines changed

common/src/api/external/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,8 @@ pub enum ResourceType {
518518
SamlIdentityProvider,
519519
SshKey,
520520
ConsoleSession,
521+
DeviceAuthRequest,
522+
DeviceAccessToken,
521523
GlobalImage,
522524
Organization,
523525
Project,

common/src/sql/dbinit.sql

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,37 +1252,36 @@ INSERT INTO omicron.public.user_builtin (
12521252
* OAuth 2.0 Device Authorization Grant (RFC 8628)
12531253
*/
12541254

1255-
-- Device authorization requests. In theory these records could
1256-
-- (and probably should) be short-lived, and removed as soon as
1257-
-- a token is granted.
1258-
-- TODO-security: We should not grant a token more than once per record.
1255+
-- Device authorization requests. These records are short-lived,
1256+
-- and removed as soon as a token is granted. This allows us to
1257+
-- use the `user_code` as primary key, despite it not having very
1258+
-- much entropy.
1259+
-- TODO: A background task should remove unused expired records.
12591260
CREATE TABLE omicron.public.device_auth_request (
1261+
user_code STRING(20) PRIMARY KEY,
12601262
client_id UUID NOT NULL,
12611263
device_code STRING(40) NOT NULL,
1262-
user_code STRING(63) NOT NULL,
12631264
time_created TIMESTAMPTZ NOT NULL,
1264-
time_expires TIMESTAMPTZ NOT NULL,
1265-
1266-
PRIMARY KEY (client_id, device_code)
1265+
time_expires TIMESTAMPTZ NOT NULL
12671266
);
12681267

1269-
-- Fast lookup by user_code for verification
1270-
CREATE INDEX ON omicron.public.device_auth_request (user_code);
1271-
12721268
-- Access tokens granted in response to successful device authorization flows.
12731269
-- TODO-security: expire tokens.
12741270
CREATE TABLE omicron.public.device_access_token (
12751271
token STRING(40) PRIMARY KEY,
12761272
client_id UUID NOT NULL,
12771273
device_code STRING(40) NOT NULL,
12781274
silo_user_id UUID NOT NULL,
1279-
time_created TIMESTAMPTZ NOT NULL
1275+
time_requested TIMESTAMPTZ NOT NULL,
1276+
time_created TIMESTAMPTZ NOT NULL,
1277+
time_expires TIMESTAMPTZ
12801278
);
12811279

1282-
-- Matches the primary key on device authorization records.
1283-
-- The UNIQUE constraint is critical for ensuring that at most
1280+
-- This UNIQUE constraint is critical for ensuring that at most
12841281
-- one token is ever created for a given device authorization flow.
1285-
CREATE UNIQUE INDEX ON omicron.public.device_access_token (client_id, device_code);
1282+
CREATE UNIQUE INDEX ON omicron.public.device_access_token (
1283+
client_id, device_code
1284+
);
12861285

12871286
/*
12881287
* Roles built into the system

nexus/src/app/device_auth.rs

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
//! automatic case, it may supply the `user_code` as a query parameter.
3030
//! 5. The user logs in using their configured IdP, then enters or verifies
3131
//! the `user_code`.
32-
//! 6. On successful login, the server is notified and responds to the
33-
//! poll started in step 3 with a freshly granted access token.
32+
//! 6. On successful login, the server responds to the poll started
33+
//! in step 3 with a freshly granted access token.
3434
//!
3535
//! Note that in this flow, there are actually two distinct sets of
3636
//! connections made to the server: by the client itself, and by the
@@ -45,44 +45,94 @@
4545
//! but that may change in the future.
4646
4747
use crate::authn::{Actor, Reason};
48+
use crate::authz;
4849
use crate::context::OpContext;
50+
use crate::db::lookup::LookupPath;
4951
use crate::db::model::{DeviceAccessToken, DeviceAuthRequest};
5052
use crate::external_api::device_auth::DeviceAccessTokenResponse;
51-
use omicron_common::api::external::CreateResult;
53+
54+
use omicron_common::api::external::{CreateResult, Error};
55+
56+
use chrono::Utc;
5257
use uuid::Uuid;
5358

5459
impl super::Nexus {
5560
/// Start a device authorization grant flow.
5661
/// Corresponds to steps 1 & 2 in the flow description above.
57-
pub async fn device_auth_request(
62+
pub async fn device_auth_request_create(
5863
&self,
5964
opctx: &OpContext,
6065
client_id: Uuid,
6166
) -> CreateResult<DeviceAuthRequest> {
67+
// TODO-correctness: the `user_code` generated for a new request
68+
// is used as a primary key, but may potentially collide with an
69+
// existing outstanding request. So we should retry some (small)
70+
// number of times if inserting the new request fails.
6271
let auth_request = DeviceAuthRequest::new(client_id);
63-
self.db_datastore.device_auth_start(opctx, auth_request).await
72+
self.db_datastore.device_auth_request_create(opctx, auth_request).await
6473
}
6574

66-
/// Verify a device authorization grant.
75+
/// Verify a device authorization grant, and delete the authorization
76+
/// request so that at most one token will be granted per request.
77+
/// Invoked in response to a request from the browser, not the client.
6778
/// Corresponds to step 5 in the flow description above.
68-
pub async fn device_auth_verify(
79+
pub async fn device_auth_request_verify(
6980
&self,
7081
opctx: &OpContext,
7182
user_code: String,
7283
silo_user_id: Uuid,
7384
) -> CreateResult<DeviceAccessToken> {
74-
let auth_request =
75-
self.db_datastore.device_auth_get_request(opctx, user_code).await?;
76-
let client_id = auth_request.client_id;
77-
let device_code = auth_request.device_code;
78-
let token =
79-
DeviceAccessToken::new(client_id, device_code, silo_user_id);
80-
self.db_datastore.device_auth_grant(opctx, token).await
85+
let (.., authz_request, db_request) =
86+
LookupPath::new(opctx, &self.db_datastore)
87+
.device_auth_request(&user_code)
88+
.fetch()
89+
.await?;
90+
91+
let (.., authz_user) = LookupPath::new(opctx, &self.datastore())
92+
.silo_user_id(silo_user_id)
93+
.lookup_for(authz::Action::CreateChild)
94+
.await?;
95+
assert_eq!(authz_user.id(), silo_user_id);
96+
97+
// Create an access token record.
98+
let token = DeviceAccessToken::new(
99+
db_request.client_id,
100+
db_request.device_code,
101+
db_request.time_created,
102+
silo_user_id,
103+
);
104+
105+
if db_request.time_expires < Utc::now() {
106+
// Store the expired token anyway so that the client
107+
// can get a proper "denied" message on its next poll.
108+
let token = token.expires(db_request.time_expires);
109+
self.db_datastore
110+
.device_access_token_create(
111+
opctx,
112+
&authz_request,
113+
&authz_user,
114+
token,
115+
)
116+
.await?;
117+
Err(Error::InvalidRequest {
118+
message: "device authorization request expired".to_string(),
119+
})
120+
} else {
121+
// TODO-security: set an expiration time for the valid token.
122+
self.db_datastore
123+
.device_access_token_create(
124+
opctx,
125+
&authz_request,
126+
&authz_user,
127+
token,
128+
)
129+
.await
130+
}
81131
}
82132

83133
/// Look up a possibly-not-yet-granted device access token.
84134
/// Corresponds to steps 3 & 6 in the flow description above.
85-
pub async fn device_access_token_lookup(
135+
pub async fn device_access_token_fetch(
86136
&self,
87137
opctx: &OpContext,
88138
client_id: Uuid,
@@ -91,7 +141,7 @@ impl super::Nexus {
91141
use DeviceAccessTokenResponse::*;
92142
match self
93143
.db_datastore
94-
.device_auth_get_token(opctx, client_id, device_code)
144+
.device_access_token_fetch(opctx, client_id, device_code)
95145
.await
96146
{
97147
Ok(token) => Ok(Granted(token)),
@@ -107,12 +157,30 @@ impl super::Nexus {
107157
opctx: &OpContext,
108158
token: String,
109159
) -> Result<Actor, Reason> {
110-
match self.db_datastore.device_access_token_actor(opctx, token).await {
111-
Ok(actor) => Ok(actor),
112-
// TODO: better error handling
113-
Err(_) => Err(Reason::UnknownActor {
114-
actor: String::from("from bearer token"),
115-
}),
116-
}
160+
let (.., db_access_token) = LookupPath::new(opctx, &self.db_datastore)
161+
.device_access_token(&token)
162+
.fetch()
163+
.await
164+
.map_err(|e| match e {
165+
Error::ObjectNotFound { .. } => Reason::UnknownActor {
166+
actor: "from device access token".to_string(),
167+
},
168+
e => Reason::UnknownError { source: e },
169+
})?;
170+
171+
let silo_user_id = db_access_token.silo_user_id;
172+
let (.., db_silo_user) = LookupPath::new(opctx, &self.db_datastore)
173+
.silo_user_id(silo_user_id)
174+
.fetch()
175+
.await
176+
.map_err(|e| match e {
177+
Error::ObjectNotFound { .. } => {
178+
Reason::UnknownActor { actor: silo_user_id.to_string() }
179+
}
180+
e => Reason::UnknownError { source: e },
181+
})?;
182+
let silo_id = db_silo_user.silo_id;
183+
184+
Ok(Actor::SiloUser { silo_user_id, silo_id })
117185
}
118186
}

nexus/src/authz/api_resources.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ impl db::model::DatabaseString for FleetRole {
239239
}
240240
}
241241

242+
// TODO: refactor synthetic resources below
243+
242244
/// ConsoleSessionList is a synthetic resource used for modeling who has access
243245
/// to create sessions.
244246
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -421,6 +423,60 @@ impl AuthorizedResource for IpPoolList {
421423
}
422424
}
423425

426+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
427+
pub struct DeviceAuthRequestList;
428+
/// Singleton representing the [`DeviceAuthRequestList`] itself for authz purposes
429+
pub const DEVICE_AUTH_REQUEST_LIST: DeviceAuthRequestList =
430+
DeviceAuthRequestList;
431+
432+
impl oso::PolarClass for DeviceAuthRequestList {
433+
fn get_polar_class_builder() -> oso::ClassBuilder<Self> {
434+
oso::Class::builder()
435+
.with_equality_check()
436+
.add_attribute_getter("fleet", |_| FLEET)
437+
}
438+
}
439+
440+
impl AuthorizedResource for DeviceAuthRequestList {
441+
fn load_roles<'a, 'b, 'c, 'd, 'e, 'f>(
442+
&'a self,
443+
opctx: &'b OpContext,
444+
datastore: &'c DataStore,
445+
authn: &'d authn::Context,
446+
roleset: &'e mut RoleSet,
447+
) -> futures::future::BoxFuture<'f, Result<(), Error>>
448+
where
449+
'a: 'f,
450+
'b: 'f,
451+
'c: 'f,
452+
'd: 'f,
453+
'e: 'f,
454+
{
455+
// There are no roles on the DeviceAuthRequestList, only permissions. But we
456+
// still need to load the Fleet-related roles to verify that the actor has the
457+
// "admin" role on the Fleet.
458+
load_roles_for_resource(
459+
opctx,
460+
datastore,
461+
authn,
462+
ResourceType::Fleet,
463+
*FLEET_ID,
464+
roleset,
465+
)
466+
.boxed()
467+
}
468+
469+
fn on_unauthorized(
470+
&self,
471+
_: &Authz,
472+
error: Error,
473+
_: AnyActor,
474+
_: Action,
475+
) -> Error {
476+
error
477+
}
478+
}
479+
424480
// Main resource hierarchy: Organizations, Projects, and their resources
425481

426482
authz_resource! {
@@ -602,6 +658,22 @@ authz_resource! {
602658
polar_snippet = FleetChild,
603659
}
604660

661+
authz_resource! {
662+
name = "DeviceAuthRequest",
663+
parent = "Fleet",
664+
primary_key = String, // user_code
665+
roles_allowed = false,
666+
polar_snippet = FleetChild,
667+
}
668+
669+
authz_resource! {
670+
name = "DeviceAccessToken",
671+
parent = "Fleet",
672+
primary_key = String, // token
673+
roles_allowed = false,
674+
polar_snippet = FleetChild,
675+
}
676+
605677
authz_resource! {
606678
name = "RoleBuiltin",
607679
parent = "Fleet",

nexus/src/authz/omicron.polar

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,18 +355,33 @@ resource ConsoleSessionList {
355355
has_relation(fleet: Fleet, "parent_fleet", collection: ConsoleSessionList)
356356
if collection.fleet = fleet;
357357

358+
# Describes the policy for creating and managing device authorization requests.
359+
resource DeviceAuthRequestList {
360+
permissions = [ "create_child" ];
361+
relations = { parent_fleet: Fleet };
362+
"create_child" if "external-authenticator" on "parent_fleet";
363+
}
364+
has_relation(fleet: Fleet, "parent_fleet", collection: DeviceAuthRequestList)
365+
if collection.fleet = fleet;
366+
358367
# These rules grants the external authenticator role the permissions it needs to
359368
# read silo users and modify their sessions. This is necessary for login to
360369
# work.
361370
has_permission(actor: AuthenticatedActor, "read", silo: Silo)
362371
if has_role(actor, "external-authenticator", silo.fleet);
363372
has_permission(actor: AuthenticatedActor, "read", user: SiloUser)
364373
if has_role(actor, "external-authenticator", user.silo.fleet);
374+
365375
has_permission(actor: AuthenticatedActor, "read", session: ConsoleSession)
366376
if has_role(actor, "external-authenticator", session.fleet);
367377
has_permission(actor: AuthenticatedActor, "modify", session: ConsoleSession)
368378
if has_role(actor, "external-authenticator", session.fleet);
369379

380+
has_permission(actor: AuthenticatedActor, "read", device_auth: DeviceAuthRequest)
381+
if has_role(actor, "external-authenticator", device_auth.fleet);
382+
has_permission(actor: AuthenticatedActor, "read", device_token: DeviceAccessToken)
383+
if has_role(actor, "external-authenticator", device_token.fleet);
384+
370385
has_permission(actor: AuthenticatedActor, "read", identity_provider: IdentityProvider)
371386
if has_role(actor, "external-authenticator", identity_provider.silo.fleet);
372387
has_permission(actor: AuthenticatedActor, "list_identity_providers", identity_provider: IdentityProvider)

nexus/src/authz/oso_generic.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result<Oso, anyhow::Error> {
4646
IpPoolList::get_polar_class(),
4747
GlobalImageList::get_polar_class(),
4848
ConsoleSessionList::get_polar_class(),
49+
DeviceAuthRequestList::get_polar_class(),
4950
];
5051
for c in classes {
5152
info!(log, "registering Oso class"; "class" => &c.name);
@@ -66,6 +67,8 @@ pub fn make_omicron_oso(log: &slog::Logger) -> Result<Oso, anyhow::Error> {
6667
VpcSubnet::init(),
6768
// Fleet-level resources
6869
ConsoleSession::init(),
70+
DeviceAuthRequest::init(),
71+
DeviceAccessToken::init(),
6972
Rack::init(),
7073
RoleBuiltin::init(),
7174
SshKey::init(),

0 commit comments

Comments
 (0)