Skip to main content

s2_api/v1/
access.rs

1use s2_common::{
2    self,
3    access::{AccessTokenId, AccessTokenIdPrefix, AccessTokenIdStartAfter},
4    basin::{BasinName, BasinNamePrefix},
5    stream::{StreamName, StreamNamePrefix},
6};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone)]
10#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
11pub enum MaybeEmpty<T> {
12    Empty,
13    NonEmpty(T),
14}
15
16impl<T: Serialize> Serialize for MaybeEmpty<T> {
17    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
18    where
19        S: serde::Serializer,
20    {
21        match self {
22            Self::NonEmpty(v) => v.serialize(serializer),
23            Self::Empty => serializer.serialize_str(""),
24        }
25    }
26}
27
28impl<'de, T> Deserialize<'de> for MaybeEmpty<T>
29where
30    T: Deserialize<'de>,
31{
32    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
33    where
34        D: serde::Deserializer<'de>,
35    {
36        let s = String::deserialize(deserializer)?;
37        if s.is_empty() {
38            Ok(MaybeEmpty::Empty)
39        } else {
40            T::deserialize(serde::de::value::StringDeserializer::new(s)).map(MaybeEmpty::NonEmpty)
41        }
42    }
43}
44
45use time::OffsetDateTime;
46
47#[rustfmt::skip]
48#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
49#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
50#[serde(rename_all = "kebab-case")]
51pub enum Operation {
52    /// List basins.
53    ListBasins,
54    /// Create a basin.
55    CreateBasin,
56    /// Delete a basin.
57    DeleteBasin,
58    /// Reconfigure a basin.
59    ReconfigureBasin,
60    /// Get basin configuration.
61    GetBasinConfig,
62    /// Issue an access token.
63    IssueAccessToken,
64    /// Revoke an access token.
65    RevokeAccessToken,
66    /// List access tokens.
67    ListAccessTokens,
68    /// List streams.
69    ListStreams,
70    /// Create a stream.
71    CreateStream,
72    /// Delete a stream.
73    DeleteStream,
74    /// Get stream configuration.
75    GetStreamConfig,
76    /// Reconfigure a stream.
77    ReconfigureStream,
78    /// Check the tail of a stream.
79    CheckTail,
80    /// Append records to a stream.
81    Append,
82    /// Read records from a stream.
83    Read,
84    /// Trim records on a stream.
85    Trim,
86    /// Set the fencing token on a stream.
87    Fence,
88    /// Retrieve account-level metrics.
89    AccountMetrics,
90    /// Retrieve basin-level metrics.
91    BasinMetrics,
92    /// Retrieve stream-level metrics.
93    StreamMetrics,
94    /// List locations.
95    ListLocations,
96    /// Get the default location.
97    GetDefaultLocation,
98    /// Set the default location.
99    SetDefaultLocation,
100}
101
102impl From<Operation> for s2_common::access::Operation {
103    fn from(value: Operation) -> Self {
104        match value {
105            Operation::ListBasins => Self::ListBasins,
106            Operation::CreateBasin => Self::CreateBasin,
107            Operation::DeleteBasin => Self::DeleteBasin,
108            Operation::ReconfigureBasin => Self::ReconfigureBasin,
109            Operation::GetBasinConfig => Self::GetBasinConfig,
110            Operation::IssueAccessToken => Self::IssueAccessToken,
111            Operation::RevokeAccessToken => Self::RevokeAccessToken,
112            Operation::ListAccessTokens => Self::ListAccessTokens,
113            Operation::ListStreams => Self::ListStreams,
114            Operation::CreateStream => Self::CreateStream,
115            Operation::DeleteStream => Self::DeleteStream,
116            Operation::GetStreamConfig => Self::GetStreamConfig,
117            Operation::ReconfigureStream => Self::ReconfigureStream,
118            Operation::CheckTail => Self::CheckTail,
119            Operation::Append => Self::Append,
120            Operation::Read => Self::Read,
121            Operation::Trim => Self::Trim,
122            Operation::Fence => Self::Fence,
123            Operation::AccountMetrics => Self::AccountMetrics,
124            Operation::BasinMetrics => Self::BasinMetrics,
125            Operation::StreamMetrics => Self::StreamMetrics,
126            Operation::ListLocations => Self::ListLocations,
127            Operation::GetDefaultLocation => Self::GetDefaultLocation,
128            Operation::SetDefaultLocation => Self::SetDefaultLocation,
129        }
130    }
131}
132
133impl From<s2_common::access::Operation> for Operation {
134    fn from(value: s2_common::access::Operation) -> Self {
135        use s2_common::access::Operation::*;
136        match value {
137            ListBasins => Self::ListBasins,
138            CreateBasin => Self::CreateBasin,
139            DeleteBasin => Self::DeleteBasin,
140            ReconfigureBasin => Self::ReconfigureBasin,
141            GetBasinConfig => Self::GetBasinConfig,
142            IssueAccessToken => Self::IssueAccessToken,
143            RevokeAccessToken => Self::RevokeAccessToken,
144            ListAccessTokens => Self::ListAccessTokens,
145            ListStreams => Self::ListStreams,
146            CreateStream => Self::CreateStream,
147            DeleteStream => Self::DeleteStream,
148            GetStreamConfig => Self::GetStreamConfig,
149            ReconfigureStream => Self::ReconfigureStream,
150            CheckTail => Self::CheckTail,
151            Append => Self::Append,
152            Read => Self::Read,
153            Trim => Self::Trim,
154            Fence => Self::Fence,
155            AccountMetrics => Self::AccountMetrics,
156            BasinMetrics => Self::BasinMetrics,
157            StreamMetrics => Self::StreamMetrics,
158            ListLocations => Self::ListLocations,
159            GetDefaultLocation => Self::GetDefaultLocation,
160            SetDefaultLocation => Self::SetDefaultLocation,
161        }
162    }
163}
164
165#[rustfmt::skip]
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
168pub struct AccessTokenInfo {
169    /// Access token ID.
170    pub id: AccessTokenId,
171    /// Expiration time in RFC 3339 format.
172    #[serde(default, with = "time::serde::rfc3339::option")]
173    pub expires_at: Option<OffsetDateTime>,
174    /// Namespace streams based on the configured stream-level scope.
175    pub auto_prefix_streams: bool,
176    /// Access token scope.
177    pub scope: AccessTokenScope,
178}
179
180impl From<s2_common::access::AccessTokenInfo> for AccessTokenInfo {
181    fn from(value: s2_common::access::AccessTokenInfo) -> Self {
182        Self {
183            id: value.id,
184            expires_at: value.expires_at,
185            auto_prefix_streams: value.auto_prefix_streams,
186            scope: value.scope.into(),
187        }
188    }
189}
190
191#[rustfmt::skip]
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
194pub struct IssueAccessTokenRequest {
195    /// Access token ID.
196    /// It must be unique to the account and between 1 and 96 bytes in length, and must not
197    /// contain NUL bytes.
198    pub id: AccessTokenId,
199    /// Expiration time in RFC 3339 format.
200    /// If not set, the expiration will be set to that of the requestor's token.
201    #[serde(default, with = "time::serde::rfc3339::option")]
202    pub expires_at: Option<OffsetDateTime>,
203    /// Namespace streams based on the configured stream-level scope, which must be a prefix.
204    /// Stream name arguments will be automatically prefixed, and the prefix will be stripped when listing streams.
205    #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))]
206    pub auto_prefix_streams: Option<bool>,
207    /// Access token scope.
208    pub scope: AccessTokenScope,
209}
210
211impl TryFrom<IssueAccessTokenRequest> for s2_common::access::IssueAccessTokenRequest {
212    type Error = s2_common::ValidationError;
213
214    fn try_from(value: IssueAccessTokenRequest) -> Result<Self, Self::Error> {
215        Ok(Self {
216            id: value.id,
217            expires_at: value.expires_at,
218            auto_prefix_streams: value.auto_prefix_streams.unwrap_or_default(),
219            scope: value.scope.try_into()?,
220        })
221    }
222}
223
224#[rustfmt::skip]
225#[derive(Debug, Clone, Serialize, Deserialize)]
226#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
227pub struct AccessTokenScope {
228    /// Basin names allowed.
229    pub basins: Option<ResourceSet<MaybeEmpty<BasinName>, BasinNamePrefix>>,
230    /// Stream names allowed.
231    pub streams: Option<ResourceSet<MaybeEmpty<StreamName>, StreamNamePrefix>>,
232    /// Token IDs allowed.
233    pub access_tokens:  Option<ResourceSet<MaybeEmpty<AccessTokenId>, AccessTokenIdPrefix>>,
234    /// Access permissions at operation group level.
235    pub op_groups: Option<PermittedOperationGroups>,
236    /// Operations allowed for the token.
237    /// A union of allowed operations and groups is used as an effective set of allowed operations.
238    #[cfg_attr(feature = "utoipa", schema(required = false))]
239    pub ops: Option<Vec<Operation>>,
240}
241
242impl TryFrom<AccessTokenScope> for s2_common::access::AccessTokenScope {
243    type Error = s2_common::ValidationError;
244
245    fn try_from(value: AccessTokenScope) -> Result<Self, Self::Error> {
246        let AccessTokenScope {
247            basins,
248            streams,
249            access_tokens,
250            op_groups,
251            ops,
252        } = value;
253
254        Ok(Self {
255            basins: basins.map(Into::into).unwrap_or_default(),
256            streams: streams.map(Into::into).unwrap_or_default(),
257            access_tokens: access_tokens.map(Into::into).unwrap_or_default(),
258            op_groups: op_groups.map(Into::into).unwrap_or_default(),
259            ops: ops
260                .map(|o| {
261                    o.into_iter()
262                        .map(s2_common::access::Operation::from)
263                        .collect()
264                })
265                .unwrap_or_default(),
266        })
267    }
268}
269
270impl From<s2_common::access::AccessTokenScope> for AccessTokenScope {
271    fn from(value: s2_common::access::AccessTokenScope) -> Self {
272        let s2_common::access::AccessTokenScope {
273            basins,
274            streams,
275            access_tokens,
276            op_groups,
277            ops,
278        } = value;
279
280        Self {
281            basins: ResourceSet::to_opt(basins),
282            streams: ResourceSet::to_opt(streams),
283            access_tokens: ResourceSet::to_opt(access_tokens),
284            op_groups: Some(op_groups.into()),
285            ops: Some(ops.into_iter().map(Operation::from).collect()),
286        }
287    }
288}
289
290#[rustfmt::skip]
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
293#[serde(rename_all = "kebab-case")]
294pub enum ResourceSet<E, P> {
295    /// Match only the resource with this exact name.
296    /// Use an empty string to match no resources.
297    #[cfg_attr(feature = "utoipa", schema(title = "exact", value_type = String))]
298    Exact(E),
299    /// Match all resources that start with this prefix.
300    /// Use an empty string to match all resource.
301    #[cfg_attr(feature = "utoipa", schema(title = "prefix", value_type = String))]
302    Prefix(P),
303}
304
305impl<E, P> ResourceSet<MaybeEmpty<E>, P> {
306    pub fn to_opt(rs: s2_common::access::ResourceSet<E, P>) -> Option<Self> {
307        match rs {
308            s2_common::access::ResourceSet::None => None,
309            s2_common::access::ResourceSet::Exact(e) => {
310                Some(ResourceSet::Exact(MaybeEmpty::NonEmpty(e)))
311            }
312            s2_common::access::ResourceSet::Prefix(p) => Some(ResourceSet::Prefix(p)),
313        }
314    }
315}
316
317impl<E, P> From<ResourceSet<MaybeEmpty<E>, P>> for s2_common::access::ResourceSet<E, P> {
318    fn from(value: ResourceSet<MaybeEmpty<E>, P>) -> Self {
319        match value {
320            ResourceSet::Exact(MaybeEmpty::Empty) => Self::None,
321            ResourceSet::Exact(MaybeEmpty::NonEmpty(e)) => Self::Exact(e),
322            ResourceSet::Prefix(p) => Self::Prefix(p),
323        }
324    }
325}
326
327#[rustfmt::skip]
328#[derive(Debug, Clone, Serialize, Deserialize)]
329#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
330pub struct PermittedOperationGroups {
331    /// Account-level access permissions.
332    pub account: Option<ReadWritePermissions>,
333    /// Basin-level access permissions.
334    pub basin: Option<ReadWritePermissions>,
335    /// Stream-level access permissions.
336    pub stream: Option<ReadWritePermissions>,
337}
338
339impl From<PermittedOperationGroups> for s2_common::access::PermittedOperationGroups {
340    fn from(value: PermittedOperationGroups) -> Self {
341        let PermittedOperationGroups {
342            account,
343            basin,
344            stream,
345        } = value;
346
347        Self {
348            account: account.map(Into::into).unwrap_or_default(),
349            basin: basin.map(Into::into).unwrap_or_default(),
350            stream: stream.map(Into::into).unwrap_or_default(),
351        }
352    }
353}
354
355impl From<s2_common::access::PermittedOperationGroups> for PermittedOperationGroups {
356    fn from(value: s2_common::access::PermittedOperationGroups) -> Self {
357        let s2_common::access::PermittedOperationGroups {
358            account,
359            basin,
360            stream,
361        } = value;
362
363        Self {
364            account: Some(account.into()),
365            basin: Some(basin.into()),
366            stream: Some(stream.into()),
367        }
368    }
369}
370
371#[rustfmt::skip]
372#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
373#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
374pub struct ReadWritePermissions {
375    /// Read permission.
376    #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))]
377    pub read: Option<bool>,
378    /// Write permission.
379    #[cfg_attr(feature = "utoipa", schema(value_type = bool, default = false, required = false))]
380    pub write: Option<bool>,
381}
382
383impl From<ReadWritePermissions> for s2_common::access::ReadWritePermissions {
384    fn from(value: ReadWritePermissions) -> Self {
385        let ReadWritePermissions { read, write } = value;
386
387        Self {
388            read: read.unwrap_or_default(),
389            write: write.unwrap_or_default(),
390        }
391    }
392}
393
394impl From<s2_common::access::ReadWritePermissions> for ReadWritePermissions {
395    fn from(value: s2_common::access::ReadWritePermissions) -> Self {
396        let s2_common::access::ReadWritePermissions { read, write } = value;
397
398        Self {
399            read: Some(read),
400            write: Some(write),
401        }
402    }
403}
404
405#[rustfmt::skip]
406#[derive(Debug, Clone, Serialize, Deserialize)]
407#[cfg_attr(feature = "utoipa", derive(utoipa::IntoParams))]
408#[cfg_attr(feature = "utoipa", into_params(parameter_in = Query))]
409pub struct ListAccessTokensRequest {
410    /// Filter to access tokens whose IDs begin with this prefix.
411    /// It must not contain NUL bytes.
412    #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))]
413    pub prefix: Option<AccessTokenIdPrefix>,
414    /// Filter to access tokens whose IDs lexicographically start after this string.
415    /// It must not contain NUL bytes.
416    #[cfg_attr(feature = "utoipa", param(value_type = String, default = "", required = false))]
417    pub start_after: Option<AccessTokenIdStartAfter>,
418    /// Number of results, up to a maximum of 1000.
419    #[cfg_attr(feature = "utoipa", param(value_type = usize, maximum = 1000, default = 1000, required = false))]
420    pub limit: Option<usize>,
421}
422
423super::impl_list_request_conversions!(
424    ListAccessTokensRequest,
425    AccessTokenIdPrefix,
426    AccessTokenIdStartAfter
427);
428
429#[rustfmt::skip]
430#[derive(Debug, Clone, Serialize, Deserialize)]
431#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
432pub struct ListAccessTokensResponse {
433    /// Matching access tokens.
434    #[cfg_attr(feature = "utoipa", schema(max_items = 1000))]
435    pub access_tokens: Vec<AccessTokenInfo>,
436    /// Indicates that there are more access tokens that match the criteria.
437    pub has_more: bool,
438}
439
440#[rustfmt::skip]
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
443pub struct IssueAccessTokenResponse {
444    /// Created access token.
445    pub access_token: String,
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn empty_exact_converts_to_resource_set_none() {
454        let json = serde_json::json!({
455            "id": "test-token",
456            "scope": {
457                "streams": {"exact": ""},
458                "basins": {"exact": ""},
459                "access_tokens": {"exact": ""}
460            }
461        });
462
463        let parsed: IssueAccessTokenRequest = serde_json::from_value(json).unwrap();
464        let internal: s2_common::access::IssueAccessTokenRequest = parsed.try_into().unwrap();
465
466        assert!(matches!(
467            internal.scope.streams,
468            s2_common::access::ResourceSet::None
469        ));
470        assert!(matches!(
471            internal.scope.basins,
472            s2_common::access::ResourceSet::None
473        ));
474        assert!(matches!(
475            internal.scope.access_tokens,
476            s2_common::access::ResourceSet::None
477        ));
478    }
479
480    #[test]
481    fn missing_scope_fields_default_to_resource_set_none() {
482        let json = serde_json::json!({
483            "id": "test-token",
484            "scope": {}
485        });
486
487        let parsed: IssueAccessTokenRequest = serde_json::from_value(json).unwrap();
488        let internal: s2_common::access::IssueAccessTokenRequest = parsed.try_into().unwrap();
489
490        assert!(matches!(
491            internal.scope.streams,
492            s2_common::access::ResourceSet::None
493        ));
494        assert!(matches!(
495            internal.scope.basins,
496            s2_common::access::ResourceSet::None
497        ));
498        assert!(matches!(
499            internal.scope.access_tokens,
500            s2_common::access::ResourceSet::None
501        ));
502    }
503}