Skip to content

Commit 268b7ae

Browse files
authored
feat(server): Implement the new Web API (#230)
Implements the new web API that allows us to have separate routes for each of our endpoints. Old endpoints still exist and will be removed once all clients have been updated. The new endpoints follow this design: ``` # Single-object operations (kind = object; separator = `key`, NO trailing slash) GET /v1/objects/attachments/organization=42;project=4711/dead/beef HEAD /v1/objects/attachments/organization=42;project=4711/dead/beef PUT /v1/objects/attachments/organization=42;project=4711/dead/beef PATCH /v1/objects/attachments/organization=42;project=4711/dead/beef DELETE /v1/objects/attachments/organization=42;project=4711/dead/beef # Collection-level operations (kind = objects; WITH trailing slash) GET /v1/objects/attachments/organization=42;project=4711/?prefix=dead/ POST /v1/objects/attachments/organization=42;project=4711/ # Batch endpoint (kind = batch; collection-level) POST /v1/batch/attachments/organization=42;project=4711/ ``` All clients have been updated to use the new URLs. We should only release the clients once the service is rolled out. Along with this, there's a number of internal changes: - Routes are now split into submodules. All deprecated routes are in their own module and will be removed together. - The format for scopes in the URL and in storage (backends) is now decoupled. - `ObjectPath` has become `ObjectId`, and has structured scopes with an addressable name and value. All backends now work with the new ID type. - Deserialization of scopes in the URL is implemented with the endpoints now. There are specific `CollectionParams` and `ObjectParams` types for this that convert into an `ObjectId`.
1 parent f4f01ab commit 268b7ae

File tree

21 files changed

+890
-451
lines changed

21 files changed

+890
-451
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

clients/python/src/objectstore_client/client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,9 @@ def session(self, usecase: Usecase, **scopes: str | int | bool) -> Session:
168168
f"{''.join(SCOPE_VALUE_ALLOWED_CHARS)}"
169169
)
170170

171-
formatted = f"{key}.{value}"
171+
formatted = f"{key}={value}"
172172
parts.append(formatted)
173-
scope_str = "/".join(parts)
173+
scope_str = ";".join(parts)
174174

175175
return Session(
176176
self._pool,
@@ -214,7 +214,7 @@ def _make_headers(self) -> dict[str, str]:
214214
return headers
215215

216216
def _make_url(self, key: str | None, full: bool = False) -> str:
217-
relative_path = f"/v1/{self._usecase.name}/{self._scope}/objects/{key or ''}"
217+
relative_path = f"/v1/objects/{self._usecase.name}/{self._scope}/{key or ''}"
218218
path = self._base_path.rstrip("/") + relative_path
219219
if full:
220220
return f"http://{self._pool.host}:{self._pool.port}{path}"

clients/python/tests/test_smoke.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def test_object_url() -> None:
1414

1515
assert (
1616
session.object_url("foo/bar")
17-
== "http://127.0.0.1:8888/v1/testing/org.12345/project.1337/app_slug.email_app/objects/foo/bar"
17+
== "http://127.0.0.1:8888/v1/objects/testing/org=12345;project=1337;app_slug=email_app/foo/bar"
1818
)
1919

2020

@@ -24,5 +24,5 @@ def test_object_url_with_base_path() -> None:
2424

2525
assert (
2626
session.object_url("foo/bar")
27-
== "http://127.0.0.1:8888/api/prefix/v1/testing/org.12345/project.1337/objects/foo/bar"
27+
== "http://127.0.0.1:8888/api/prefix/v1/objects/testing/org=12345;project=1337/foo/bar"
2828
)

clients/rust/src/client.rs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,14 @@ impl ScopeInner {
226226
pub(crate) fn usecase(&self) -> &Usecase {
227227
&self.usecase
228228
}
229+
230+
fn as_path_segment(&self) -> &str {
231+
if self.scope.is_empty() {
232+
"_"
233+
} else {
234+
&self.scope
235+
}
236+
}
229237
}
230238

231239
impl std::fmt::Display for ScopeInner {
@@ -254,12 +262,12 @@ impl Scope {
254262
}
255263

256264
fn for_organization(usecase: Usecase, organization: u64) -> Self {
257-
let scope = format!("org.{}", organization);
265+
let scope = format!("org={}", organization);
258266
Self(Ok(ScopeInner { usecase, scope }))
259267
}
260268

261269
fn for_project(usecase: Usecase, organization: u64, project: u64) -> Self {
262-
let scope = format!("org.{}/project.{}", organization, project);
270+
let scope = format!("org={};project={}", organization, project);
263271
Self(Ok(ScopeInner { usecase, scope }))
264272
}
265273

@@ -275,10 +283,10 @@ impl Scope {
275283
Self::validate_value(&value)?;
276284

277285
if !inner.scope.is_empty() {
278-
inner.scope.push('/');
286+
inner.scope.push(';');
279287
}
280288
inner.scope.push_str(key);
281-
inner.scope.push('.');
289+
inner.scope.push('=');
282290
inner.scope.push_str(&value);
283291

284292
Ok(inner)
@@ -429,11 +437,10 @@ impl Session {
429437
let mut segments = url.path_segments_mut().unwrap();
430438
segments
431439
.push("v1")
432-
.extend(self.scope.usecase.name.split("/"));
433-
if !self.scope.scope.is_empty() {
434-
segments.extend(self.scope.scope.split("/"));
435-
}
436-
segments.push("objects").extend(object_key.split("/"));
440+
.push("objects")
441+
.push(&self.scope.usecase.name)
442+
.push(self.scope.as_path_segment())
443+
.extend(object_key.split("/"));
437444
drop(segments);
438445

439446
url
@@ -475,7 +482,7 @@ mod tests {
475482

476483
assert_eq!(
477484
session.object_url("foo/bar").to_string(),
478-
"http://127.0.0.1:8888/v1/testing/org.12345/project.1337/app_slug.email_app/objects/foo/bar"
485+
"http://127.0.0.1:8888/v1/objects/testing/org=12345;project=1337;app_slug=email_app/foo/bar"
479486
)
480487
}
481488

@@ -488,7 +495,7 @@ mod tests {
488495

489496
assert_eq!(
490497
session.object_url("foo/bar").to_string(),
491-
"http://127.0.0.1:8888/api/prefix/v1/testing/org.12345/project.1337/objects/foo/bar"
498+
"http://127.0.0.1:8888/api/prefix/v1/objects/testing/org=12345;project=1337/foo/bar"
492499
)
493500
}
494501
}

objectstore-server/src/auth/context.rs

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::collections::{BTreeMap, HashSet};
22

33
use jsonwebtoken::{DecodingKey, Header, TokenData, Validation, decode, decode_header};
4-
use objectstore_service::ObjectPath;
4+
use objectstore_service::id::ObjectId;
55
use secrecy::ExposeSecret;
66
use serde::{Deserialize, Serialize};
77
use thiserror::Error;
@@ -35,7 +35,7 @@ pub struct AuthContext {
3535
/// The objectstore usecase that this request may act on.
3636
pub usecase: String,
3737

38-
/// The scope elements that this request may act on. See also: [`ObjectPath::scope`].
38+
/// The scope elements that this request may act on. See also: [`ObjectId::scopes`].
3939
pub scopes: BTreeMap<String, StringOrWildcard>,
4040

4141
/// The permissions that this request has been granted.
@@ -168,8 +168,8 @@ impl AuthContext {
168168
})
169169
}
170170

171-
fn fail_if_enforced(&self, perm: &Permission, path: &ObjectPath) -> Result<(), AuthError> {
172-
tracing::debug!(?self, ?perm, ?path, "Authorization failed");
171+
fn fail_if_enforced(&self, perm: &Permission, id: &ObjectId) -> Result<(), AuthError> {
172+
tracing::debug!(?self, ?perm, ?id, "Authorization failed");
173173
if self.enforce {
174174
return Err(AuthError::NotPermitted);
175175
}
@@ -181,24 +181,19 @@ impl AuthContext {
181181
///
182182
/// The passed-in `perm` is checked against this `AuthContext`'s `permissions`. If it is not
183183
/// present, then the operation is not authorized.
184-
pub fn assert_authorized(&self, perm: Permission, path: &ObjectPath) -> Result<(), AuthError> {
185-
if !self.permissions.contains(&perm) || self.usecase != path.usecase {
186-
self.fail_if_enforced(&perm, path)?;
184+
pub fn assert_authorized(&self, perm: Permission, id: &ObjectId) -> Result<(), AuthError> {
185+
if !self.permissions.contains(&perm) || self.usecase != id.usecase {
186+
self.fail_if_enforced(&perm, id)?;
187187
}
188188

189-
for element in &path.scope {
190-
let (key, value) = element
191-
.split_once('.')
192-
.ok_or(AuthError::InternalError(format!(
193-
"Invalid scope element: {element}"
194-
)))?;
195-
let authorized = match self.scopes.get(key) {
196-
Some(StringOrWildcard::String(s)) => s == value,
189+
for scope in &id.scopes {
190+
let authorized = match self.scopes.get(scope.name()) {
191+
Some(StringOrWildcard::String(s)) => s == scope.value(),
197192
Some(StringOrWildcard::Wildcard) => true,
198193
None => false,
199194
};
200195
if !authorized {
201-
self.fail_if_enforced(&perm, path)?;
196+
self.fail_if_enforced(&perm, id)?;
202197
}
203198
}
204199

@@ -210,6 +205,7 @@ impl AuthContext {
210205
mod tests {
211206
use super::*;
212207
use crate::config::{AuthZVerificationKey, ConfigSecret};
208+
use objectstore_service::id::{Scope, Scopes};
213209
use secrecy::SecretBox;
214210
use serde_json::json;
215211

@@ -397,10 +393,13 @@ mod tests {
397393
Ok(())
398394
}
399395

400-
fn sample_object_path(org: u32, proj: u32) -> ObjectPath {
401-
ObjectPath {
396+
fn sample_object_path(org: &str, project: &str) -> ObjectId {
397+
ObjectId {
402398
usecase: "attachments".into(),
403-
scope: vec![format!("org.{org}"), format!("project.{proj}")],
399+
scopes: Scopes::from_iter([
400+
Scope::create("org", org).unwrap(),
401+
Scope::create("project", project).unwrap(),
402+
]),
404403
key: "abcde".into(),
405404
}
406405
}
@@ -411,7 +410,7 @@ mod tests {
411410
#[test]
412411
fn test_assert_authorized_exact_scope_allowed() -> Result<(), AuthError> {
413412
let auth_context = sample_auth_context("123", "456", max_permission());
414-
let object = sample_object_path(123, 456);
413+
let object = sample_object_path("123", "456");
415414

416415
auth_context.assert_authorized(Permission::ObjectRead, &object)?;
417416

@@ -424,7 +423,7 @@ mod tests {
424423
#[test]
425424
fn test_assert_authorized_wildcard_project_allowed() -> Result<(), AuthError> {
426425
let auth_context = sample_auth_context("123", "*", max_permission());
427-
let object = sample_object_path(123, 456);
426+
let object = sample_object_path("123", "456");
428427

429428
auth_context.assert_authorized(Permission::ObjectRead, &object)?;
430429

@@ -437,8 +436,11 @@ mod tests {
437436
#[test]
438437
fn test_assert_authorized_org_only_path_allowed() -> Result<(), AuthError> {
439438
let auth_context = sample_auth_context("123", "456", max_permission());
440-
let mut object = sample_object_path(123, 999);
441-
object.scope.pop();
439+
let object = ObjectId {
440+
usecase: "attachments".into(),
441+
scopes: Scopes::from_iter([Scope::create("org", "123").unwrap()]),
442+
key: "abcde".into(),
443+
};
442444

443445
auth_context.assert_authorized(Permission::ObjectRead, &object)?;
444446

@@ -454,13 +456,13 @@ mod tests {
454456
#[test]
455457
fn test_assert_authorized_scope_mismatch_fails() -> Result<(), AuthError> {
456458
let auth_context = sample_auth_context("123", "456", max_permission());
457-
let object = sample_object_path(123, 999);
459+
let object = sample_object_path("123", "999");
458460

459461
let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
460462
assert_eq!(result, Err(AuthError::NotPermitted));
461463

462464
let auth_context = sample_auth_context("123", "456", max_permission());
463-
let object = sample_object_path(999, 456);
465+
let object = sample_object_path("999", "456");
464466

465467
let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
466468
assert_eq!(result, Err(AuthError::NotPermitted));
@@ -472,7 +474,7 @@ mod tests {
472474
fn test_assert_authorized_wrong_usecase_fails() -> Result<(), AuthError> {
473475
let mut auth_context = sample_auth_context("123", "456", max_permission());
474476
auth_context.usecase = "debug-files".into();
475-
let object = sample_object_path(123, 456);
477+
let object = sample_object_path("123", "456");
476478

477479
let result = auth_context.assert_authorized(Permission::ObjectRead, &object);
478480
assert_eq!(result, Err(AuthError::NotPermitted));
@@ -484,7 +486,7 @@ mod tests {
484486
fn test_assert_authorized_auth_context_missing_permission_fails() -> Result<(), AuthError> {
485487
let auth_context =
486488
sample_auth_context("123", "456", HashSet::from([Permission::ObjectRead]));
487-
let object = sample_object_path(123, 456);
489+
let object = sample_object_path("123", "456");
488490

489491
let result = auth_context.assert_authorized(Permission::ObjectWrite, &object);
490492
assert_eq!(result, Err(AuthError::NotPermitted));
@@ -498,7 +500,7 @@ mod tests {
498500
let mut auth_context =
499501
sample_auth_context("123", "456", HashSet::from([Permission::ObjectRead]));
500502
// Object's scope is not covered by the auth context
501-
let object = sample_object_path(999, 999);
503+
let object = sample_object_path("999", "999");
502504

503505
// Auth fails for two reasons, but because enforcement is off, it should not return an error
504506
auth_context.enforce = false;

objectstore-server/src/auth/service.rs

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use axum::extract::FromRequestParts;
22
use axum::http::{StatusCode, header, request::Parts};
3-
use objectstore_service::PayloadStream;
4-
use objectstore_service::{ObjectPath, StorageService};
3+
use objectstore_service::id::ObjectId;
4+
use objectstore_service::{PayloadStream, StorageService};
55
use objectstore_types::Metadata;
66

77
use crate::auth::{AuthContext, AuthError, Permission};
@@ -18,17 +18,15 @@ const BEARER_PREFIX: &str = "Bearer ";
1818
/// Objectstore API endpoints can use `AuthAwareService` simply by adding it to their handler
1919
/// function's argument list like so:
2020
///
21-
/// ```no_run
22-
/// # use axum::extract::Path;
23-
/// # use axum::response::IntoResponse;
24-
/// # use axum::http::StatusCode;
25-
/// # use objectstore_server::{auth::AuthAwareService, error::ApiResult};
26-
/// # use objectstore_service::ObjectPath;
27-
/// async fn delete_object(
28-
/// service: AuthAwareService, // <- Constructed automatically from request parts
29-
/// Path(path): Path<ObjectPath>,
30-
/// ) -> ApiResult<impl IntoResponse> {
31-
/// service.delete_object(&path).await?;
21+
/// ```
22+
/// use axum::http::StatusCode;
23+
/// use objectstore_server::auth::AuthAwareService;
24+
/// use objectstore_server::error::ApiResult;
25+
///
26+
/// async fn my_endpoint(service: AuthAwareService) -> Result<StatusCode, StatusCode> {
27+
/// service.delete_object(todo!("pass some ID"))
28+
/// .await
29+
/// .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
3230
///
3331
/// Ok(StatusCode::NO_CONTENT)
3432
/// }
@@ -41,13 +39,13 @@ pub struct AuthAwareService {
4139
}
4240

4341
impl AuthAwareService {
44-
fn assert_authorized(&self, perm: Permission, path: &ObjectPath) -> anyhow::Result<()> {
42+
fn assert_authorized(&self, perm: Permission, id: &ObjectId) -> anyhow::Result<()> {
4543
if self.enforce {
4644
let context = self
4745
.context
4846
.as_ref()
4947
.ok_or(AuthError::VerificationFailure)?;
50-
context.assert_authorized(perm, path)?;
48+
context.assert_authorized(perm, id)?;
5149
}
5250

5351
Ok(())
@@ -56,27 +54,27 @@ impl AuthAwareService {
5654
/// Auth-aware wrapper around [`StorageService::put_object`].
5755
pub async fn put_object(
5856
&self,
59-
path: ObjectPath,
57+
id: ObjectId,
6058
metadata: &Metadata,
6159
stream: PayloadStream,
62-
) -> anyhow::Result<ObjectPath> {
63-
self.assert_authorized(Permission::ObjectWrite, &path)?;
64-
self.service.put_object(path, metadata, stream).await
60+
) -> anyhow::Result<ObjectId> {
61+
self.assert_authorized(Permission::ObjectWrite, &id)?;
62+
self.service.put_object(id, metadata, stream).await
6563
}
6664

6765
/// Auth-aware wrapper around [`StorageService::get_object`].
6866
pub async fn get_object(
6967
&self,
70-
path: &ObjectPath,
68+
id: &ObjectId,
7169
) -> anyhow::Result<Option<(Metadata, PayloadStream)>> {
72-
self.assert_authorized(Permission::ObjectRead, path)?;
73-
self.service.get_object(path).await
70+
self.assert_authorized(Permission::ObjectRead, id)?;
71+
self.service.get_object(id).await
7472
}
7573

7674
/// Auth-aware wrapper around [`StorageService::delete_object`].
77-
pub async fn delete_object(&self, path: &ObjectPath) -> anyhow::Result<()> {
78-
self.assert_authorized(Permission::ObjectDelete, path)?;
79-
self.service.delete_object(path).await
75+
pub async fn delete_object(&self, id: &ObjectId) -> anyhow::Result<()> {
76+
self.assert_authorized(Permission::ObjectDelete, id)?;
77+
self.service.delete_object(id).await
8078
}
8179
}
8280

0 commit comments

Comments
 (0)