nexus-gateway 0.0.1-alpha

S3-compatible object gateway and embedded Nexus console.
use std::collections::BTreeMap;
use std::sync::Arc;

use s3s::access::{S3Access, S3AccessContext};
use s3s::path::S3Path;
use s3s::{s3_error, S3Result};

use crate::auth_provider::CredentialStore;
use crate::iam::{AuthorizationContext, IamState, PrincipalContext};

#[derive(Clone)]
pub struct GatewayAccessControl {
    pub credentials: Arc<CredentialStore>,
    pub iam: Arc<IamState>,
}

#[async_trait::async_trait]
impl S3Access for GatewayAccessControl {
    async fn check(&self, cx: &mut S3AccessContext<'_>) -> S3Result<()> {
        let creds = cx
            .credentials()
            .ok_or_else(|| s3_error!(AccessDenied, "Signature is required"))?;

        let Some(record) = self.credentials.get(&creds.access_key) else {
            return Err(s3_error!(AccessDenied));
        };

        if let Some(expected_token) = record.session_token.as_ref() {
            let provided = cx
                .headers()
                .get("x-amz-security-token")
                .and_then(|value| value.to_str().ok());
            if provided != Some(expected_token.as_str()) {
                return Err(s3_error!(AccessDenied));
            }
        }

        let action = operation_to_action(cx.s3_op().name());
        let resource = path_to_resource(cx.s3_path());

        let mut attrs = BTreeMap::new();
        attrs.insert("http:method".to_string(), cx.method().as_str().to_string());
        attrs.insert(
            "sts:authentication".to_string(),
            if record.session_token.is_some() {
                "true".to_string()
            } else {
                "false".to_string()
            },
        );

        let allowed = self.iam.evaluate(&AuthorizationContext {
            principal: PrincipalContext {
                principal: record.principal,
                access_key: creds.access_key.clone(),
            },
            action,
            resource,
            attributes: attrs,
        });

        if allowed {
            Ok(())
        } else {
            Err(s3_error!(AccessDenied))
        }
    }
}

pub fn operation_to_action(operation_name: &str) -> String {
    match operation_name {
        "ListBuckets" => "s3:ListAllMyBuckets".to_string(),
        "ListMultipartUploads" => "s3:ListBucketMultipartUploads".to_string(),
        "ListParts" => "s3:ListMultipartUploadParts".to_string(),
        "CreateMultipartUpload" | "UploadPart" | "CompleteMultipartUpload" => {
            "s3:PutObject".to_string()
        }
        "HeadObject" => "s3:ListBucket".to_string(),
        other => format!("s3:{other}"),
    }
}

pub fn path_to_resource(path: &S3Path) -> String {
    match path {
        S3Path::Root => "arn:aws:s3:::*".to_string(),
        S3Path::Bucket { bucket } => format!("arn:aws:s3:::{bucket}"),
        S3Path::Object { bucket, key } => format!("arn:aws:s3:::{bucket}/{key}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn operation_to_action_mapping() {
        assert_eq!(operation_to_action("PutObject"), "s3:PutObject");
    }

    #[test]
    fn path_to_resource_mapping() {
        assert_eq!(
            path_to_resource(&S3Path::object("bucket", "key")),
            "arn:aws:s3:::bucket/key"
        );
    }
}