diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e7f79e --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Registry URL +GRAIN_URL=http://localhost:8888 + +# Admin credentials +GRAIN_ADMIN_USER=admin +GRAIN_ADMIN_PASSWORD=admin diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4de0320..8253c8c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -22,16 +22,42 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Lint - run: cargo clippy + - name: Lint all targets + run: cargo clippy --all-targets --all-features -- -D warnings build: name: Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Build - run: cargo build --verbose + - name: Build all binaries + run: cargo build --verbose --all-targets + - name: Build grain binary + run: cargo build --verbose --bin grain + - name: Build grainctl binary + run: cargo build --verbose --bin grainctl + + build-release: + name: Build Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build release binaries + run: cargo build --release --all-targets + - name: Verify grain binary exists + run: test -f target/release/grain + - name: Verify grainctl binary exists + run: test -f target/release/grainctl + - name: Upload grain binary + uses: actions/upload-artifact@v4 + with: + name: grain-binary + path: target/release/grain + - name: Upload grainctl binary + uses: actions/upload-artifact@v4 + with: + name: grainctl-binary + path: target/release/grainctl test: name: Test @@ -39,4 +65,43 @@ jobs: steps: - uses: actions/checkout@v4 - name: Run tests - run: cargo test --verbose + run: cargo test --verbose --all-targets + + integration-test: + name: Integration Test + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + - name: Build binaries + run: cargo build --release + - name: Create test users file + run: | + mkdir -p tmp + cat > tmp/users.json << 'EOF' + { + "users": [ + { + "username": "admin", + "password": "admin", + "permissions": [{"repository": "*", "tag": "*", "actions": ["pull", "push", "delete"]}] + } + ] + } + EOF + - name: Start grain server + run: | + ./target/release/grain --host 127.0.0.1:8888 --users-file ./tmp/users.json & + sleep 3 + - name: Test server is running + run: curl -f -u admin:admin http://127.0.0.1:8888/v2/ + - name: Test grainctl list users + run: ./target/release/grainctl user list --url http://127.0.0.1:8888 --username admin --password admin + - name: Test grainctl create user + run: ./target/release/grainctl user create testuser --pass testpass --url http://127.0.0.1:8888 --username admin --password admin + - name: Test grainctl add permission + run: ./target/release/grainctl user add-permission testuser --repository "test/*" --tag "*" --actions "pull" --url http://127.0.0.1:8888 --username admin --password admin + - name: Test grainctl delete user + run: ./target/release/grainctl user delete testuser --url http://127.0.0.1:8888 --username admin --password admin + - name: Stop server + run: pkill -f "grain.*--host" || true diff --git a/AGENTS.md b/AGENTS.md index e7bb8c9..dde1171 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ A production-ready, lightweight OCI registry server featuring: - CLI tools for administration ### Current State -**Partial implementation** - basic skeleton exists but many endpoints return "not implemented": +**Full implementation** - all core OCI endpoints and administration features are complete: - ✅ Basic auth (end-1: `/v2/`) - ✅ Blob uploads (end-4a/4b: `POST /v2//blobs/uploads/`) - ✅ Manifest uploads (end-7: `PUT /v2//manifests/`) @@ -28,7 +28,8 @@ A production-ready, lightweight OCI registry server featuring: - ✅ Chunked upload operations (end-5, end-6) - ✅ Cross-repo blob mounting (end-11) - ✅ Granular tag-level permissions -- ❌ Administration API +- ✅ Administration API +- ✅ CLI administration tool (`grainctl`) ## Architecture @@ -47,13 +48,17 @@ src/ ├── args.rs - CLI argument parsing (host, users_file) ├── state.rs - Shared app state (server status, users, config) ├── auth.rs - HTTP Basic Auth parsing and validation -├── response.rs - HTTP response helpers (ok, unauthorized, not_found, etc.) +├── response.rs - HTTP response helpers (unauthorized, not_found, forbidden, etc.) ├── storage.rs - Filesystem I/O for blobs/manifests ├── blobs.rs - Blob endpoints (GET, HEAD, POST, PATCH, PUT, DELETE) ├── manifests.rs - Manifest endpoints (GET, HEAD, PUT, DELETE) ├── tags.rs - Tag listing endpoints +├── admin.rs - Administration API (user/permission management) +├── permissions.rs - Permission checking logic ├── meta.rs - Index and catch-all routes -└── utils.rs - Build version helper +├── utils.rs - Build version helper +└── bin/ + └── grainctl.rs - CLI tool for administration (separate binary) ``` ### Data Flow @@ -204,8 +209,6 @@ Scan `./tmp/manifests/{org}/{repo}/` directory: ### High Priority 1. **Error Handling** - Proper OCI error response format with error codes (see spec.md) -2. **Admin API** - REST endpoints to add/remove users, set permissions -3. **CLI Tool** - Command-line interface for administration tasks ### Medium Priority 1. **Validation** - Manifest schema validation (OCI image manifest, image index) diff --git a/Cargo.toml b/Cargo.toml index c7116b0..59ae4bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,14 @@ name = "grain" version = "0.1.0" edition = "2021" +[[bin]] +name = "grain" +path = "src/main.rs" + +[[bin]] +name = "grainctl" +path = "src/bin/grainctl.rs" + [dependencies] env_logger = "0.11.5" log = "0.4.27" @@ -16,3 +24,6 @@ base64 = "0.22.1" sha256 = "1.6.0" uuid = { version = "1.0", features = ["v4"] } bytes = "1.9.0" +utoipa = { version = "5", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "9", features = ["axum"] } +reqwest = { version = "0.12", features = ["blocking", "json"] } diff --git a/README.md b/README.md index ee46f4e..0c21fde 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,95 @@ Rust implementation of OCI Distribution Spec with granular access control ## Admin API - Add/remove users - Set pull permission for user on tag +- Interactive API documentation available at `/swagger-ui/` when server is running +- OpenAPI schema available at `/api-docs/openapi.json` + +### Admin API Endpoints + +**Authentication**: All admin endpoints require HTTP Basic Auth with admin privileges (user must have wildcard delete permission on `*/*`). + +**GET /admin/users** - List all users with their permissions + +**POST /admin/users** - Create a new user +```json +{ + "username": "string", + "password": "string", + "permissions": [ + { + "repository": "string", + "tag": "string", + "actions": ["pull", "push", "delete"] + } + ] +} +``` + +**DELETE /admin/users/{username}** - Delete a user (cannot delete yourself) + +**POST /admin/users/{username}/permissions** - Add permission to a user +```json +{ + "repository": "string", + "tag": "string", + "actions": ["pull", "push", "delete"] +} +``` + +## CLI Administration Tool + +A separate `grainctl` binary is provided for easy administration via command line. + +### Installation +```bash +cargo build --release +# Binary will be at target/release/grainctl +``` + +### Configuration +Set environment variables to avoid repeating credentials: +```bash +export GRAIN_URL=http://localhost:8888 +export GRAIN_ADMIN_USER=admin +export GRAIN_ADMIN_PASSWORD=admin +``` + +Or use command-line flags for each command. + +### Commands + +**List all users:** +```bash +grainctl user list +# or with explicit credentials: +grainctl user list --url http://localhost:8888 --username admin --password admin +``` + +**Create a new user:** +```bash +grainctl user create alice --pass alicepass +``` + +**Delete a user:** +```bash +grainctl user delete alice +``` + +**Add permission to a user:** +```bash +grainctl user add-permission alice \ + --repository "myorg/myapp" \ + --tag "dev" \ + --actions "pull,push" +``` + +Use wildcards for broader permissions: +```bash +grainctl user add-permission alice \ + --repository "myorg/*" \ + --tag "*" \ + --actions "pull" +``` ## Spec [OCI Distribution Spec v1.1.1](spec.md) \ No newline at end of file diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..9c0af18 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,349 @@ +use axum::{ + body::Body, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use utoipa::ToSchema; + +use crate::{auth, permissions, response, state}; + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct CreateUserRequest { + pub username: String, + pub password: String, + #[serde(default)] + pub permissions: Vec, +} + +#[derive(Debug, Deserialize, Serialize, ToSchema)] +pub struct AddPermissionRequest { + pub repository: String, + pub tag: String, + pub actions: Vec, +} + +/// Check if user is admin (has wildcard delete permission) +fn is_admin(user: &state::User) -> bool { + permissions::has_permission(user, "*", Some("*"), permissions::Action::Delete) +} + +/// List all users (admin only) +#[utoipa::path( + get, + path = "/admin/users", + responses( + (status = 200, description = "List of all users with their permissions", content_type = "application/json"), + (status = 401, description = "Unauthorized - authentication required"), + (status = 403, description = "Forbidden - admin permission required") + ), + security( + ("basic_auth" = []) + ) +)] +pub async fn list_users(State(state): State>, headers: HeaderMap) -> Response { + let host = &state.args.host; + + // Authenticate + let user = match auth::authenticate_user(&state, &headers).await { + Ok(u) => u, + Err(_) => return response::unauthorized(host), + }; + + // Check admin permission + if !is_admin(&user) { + return response::forbidden(); + } + + // Get users + let users = state.users.lock().await; + let user_list: Vec<_> = users + .iter() + .map(|u| { + serde_json::json!({ + "username": u.username, + "permissions": u.permissions, + }) + }) + .collect(); + + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from( + serde_json::to_string_pretty(&user_list).unwrap(), + )) + .unwrap() +} + +/// Create new user (admin only) +#[utoipa::path( + post, + path = "/admin/users", + request_body = CreateUserRequest, + responses( + (status = 201, description = "User created successfully", content_type = "application/json"), + (status = 400, description = "Bad request - invalid JSON"), + (status = 401, description = "Unauthorized - authentication required"), + (status = 403, description = "Forbidden - admin permission required"), + (status = 409, description = "Conflict - user already exists"), + (status = 500, description = "Internal server error - failed to save users") + ), + security( + ("basic_auth" = []) + ) +)] +pub async fn create_user( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Response { + let host = &state.args.host; + + // Authenticate + let user = match auth::authenticate_user(&state, &headers).await { + Ok(u) => u, + Err(_) => return response::unauthorized(host), + }; + + // Check admin permission + if !is_admin(&user) { + return response::forbidden(); + } + + // Parse request + let req: CreateUserRequest = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(e) => { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(format!("Invalid request: {}", e))) + .unwrap(); + } + }; + + // Create new user + let new_user = state::User { + username: req.username.clone(), + password: req.password, + permissions: req.permissions, + }; + + // Add to users set + { + let mut users = state.users.lock().await; + + // Check if user already exists + if users.iter().any(|u| u.username == new_user.username) { + return response::conflict("User already exists"); + } + + users.insert(new_user.clone()); + } + + // Persist to file + if let Err(e) = save_users(&state).await { + log::error!("Failed to save users: {}", e); + return response::internal_error(); + } + + log::info!("Created user: {}", new_user.username); + + Response::builder() + .status(StatusCode::CREATED) + .header("Content-Type", "application/json") + .body(Body::from( + serde_json::json!({ + "username": new_user.username, + "permissions": new_user.permissions, + }) + .to_string(), + )) + .unwrap() +} + +/// Delete user (admin only) +#[utoipa::path( + delete, + path = "/admin/users/{username}", + params( + ("username" = String, Path, description = "Username of the user to delete") + ), + responses( + (status = 204, description = "User deleted successfully"), + (status = 400, description = "Bad request - cannot delete yourself"), + (status = 401, description = "Unauthorized - authentication required"), + (status = 403, description = "Forbidden - admin permission required"), + (status = 404, description = "Not found - user does not exist"), + (status = 500, description = "Internal server error - failed to save users") + ), + security( + ("basic_auth" = []) + ) +)] +pub async fn delete_user( + State(state): State>, + Path(username): Path, + headers: HeaderMap, +) -> Response { + let host = &state.args.host; + + // Authenticate + let user = match auth::authenticate_user(&state, &headers).await { + Ok(u) => u, + Err(_) => return response::unauthorized(host), + }; + + // Check admin permission + if !is_admin(&user) { + return response::forbidden(); + } + + // Prevent deleting yourself + if user.username == username { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from("Cannot delete yourself")) + .unwrap(); + } + + // Remove user + { + let mut users = state.users.lock().await; + let before_len = users.len(); + users.retain(|u| u.username != username); + + if users.len() == before_len { + return response::not_found(); + } + } + + // Persist to file + if let Err(e) = save_users(&state).await { + log::error!("Failed to save users: {}", e); + return response::internal_error(); + } + + log::info!("Deleted user: {}", username); + + response::no_content() +} + +/// Add permission to user (admin only) +#[utoipa::path( + post, + path = "/admin/users/{username}/permissions", + params( + ("username" = String, Path, description = "Username of the user to add permission to") + ), + request_body = AddPermissionRequest, + responses( + (status = 200, description = "Permission added successfully", content_type = "application/json"), + (status = 400, description = "Bad request - invalid JSON"), + (status = 401, description = "Unauthorized - authentication required"), + (status = 403, description = "Forbidden - admin permission required"), + (status = 404, description = "Not found - user does not exist"), + (status = 500, description = "Internal server error - failed to save users") + ), + security( + ("basic_auth" = []) + ) +)] +pub async fn add_permission( + State(state): State>, + Path(username): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + let host = &state.args.host; + + // Authenticate + let user = match auth::authenticate_user(&state, &headers).await { + Ok(u) => u, + Err(_) => return response::unauthorized(host), + }; + + // Check admin permission + if !is_admin(&user) { + return response::forbidden(); + } + + // Parse request + let req: AddPermissionRequest = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(e) => { + return Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::from(format!("Invalid request: {}", e))) + .unwrap(); + } + }; + + let new_permission = state::Permission { + repository: req.repository, + tag: req.tag, + actions: req.actions, + }; + + // Add permission to user + { + let mut users = state.users.lock().await; + let mut user_found = false; + + // Create new set with updated user + let updated_users: std::collections::HashSet<_> = users + .iter() + .map(|u| { + if u.username == username { + user_found = true; + let mut updated = u.clone(); + updated.permissions.push(new_permission.clone()); + updated + } else { + u.clone() + } + }) + .collect(); + + if !user_found { + return response::not_found(); + } + + *users = updated_users; + } + + // Persist to file + if let Err(e) = save_users(&state).await { + log::error!("Failed to save users: {}", e); + return response::internal_error(); + } + + log::info!( + "Added permission for user {}: {:?}", + username, + new_permission + ); + + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from(serde_json::to_string(&new_permission).unwrap())) + .unwrap() +} + +/// Save users to file +async fn save_users(state: &Arc) -> Result<(), Box> { + let users = state.users.lock().await; + + let users_file = state::UsersFile { + users: users.iter().cloned().collect(), + }; + + let json = serde_json::to_string_pretty(&users_file)?; + std::fs::write(&state.args.users_file, json)?; + + Ok(()) +} diff --git a/src/auth.rs b/src/auth.rs index 8f674f0..14f4c05 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -2,9 +2,10 @@ use base64::{prelude::BASE64_STANDARD, Engine}; use std::sync::Arc; use crate::permissions::{has_permission, Action}; -use crate::response::{ok, unauthorized}; +use crate::response::unauthorized; use crate::state::{self, User}; use axum::{ + body::Body, extract::State, http::{HeaderMap, Response}, }; @@ -69,16 +70,16 @@ pub async fn check_permission( } } -pub(crate) async fn get( - State(data): State>, - headers: HeaderMap, -) -> Response { +pub(crate) async fn get(State(data): State>, headers: HeaderMap) -> Response { log::info!("Incoming request headers: {:?}", headers); match authenticate_user(&data, &headers).await { Ok(user) => { log::info!("User {} authenticated successfully", user.username); - ok() + Response::builder() + .status(200) + .body(Body::from("200 OK")) + .unwrap() } Err(_) => { log::warn!("Authentication failed"); diff --git a/src/bin/grainctl.rs b/src/bin/grainctl.rs new file mode 100644 index 0000000..f7251af --- /dev/null +++ b/src/bin/grainctl.rs @@ -0,0 +1,235 @@ +use clap::{Parser, Subcommand}; +use reqwest::blocking::Client; +use serde_json::json; +use std::process; + +#[derive(Parser)] +#[command(name = "grainctl")] +#[command(about = "CLI tool for administering the grain OCI registry", long_about = None)] +#[command(version)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// User management + User { + #[command(subcommand)] + command: UserCommands, + }, +} + +#[derive(Subcommand)] +enum UserCommands { + /// List all users + List { + #[arg(long, env = "GRAIN_URL")] + url: String, + + #[arg(long, env = "GRAIN_ADMIN_USER")] + username: String, + + #[arg(long, env = "GRAIN_ADMIN_PASSWORD")] + password: String, + }, + + /// Create a new user + Create { + /// Username for the new user + user: String, + + /// Password for the new user + #[arg(long)] + pass: String, + + #[arg(long, env = "GRAIN_URL")] + url: String, + + #[arg(long, env = "GRAIN_ADMIN_USER")] + username: String, + + #[arg(long, env = "GRAIN_ADMIN_PASSWORD")] + password: String, + }, + + /// Delete a user + Delete { + /// Username to delete + user: String, + + #[arg(long, env = "GRAIN_URL")] + url: String, + + #[arg(long, env = "GRAIN_ADMIN_USER")] + username: String, + + #[arg(long, env = "GRAIN_ADMIN_PASSWORD")] + password: String, + }, + + /// Add permission to a user + AddPermission { + /// Target username + user: String, + + /// Repository pattern (e.g., "myorg/myrepo" or "myorg/*") + #[arg(long)] + repository: String, + + /// Tag pattern (e.g., "latest" or "v*") + #[arg(long)] + tag: String, + + /// Actions (comma-separated: pull,push,delete) + #[arg(long)] + actions: String, + + #[arg(long, env = "GRAIN_URL")] + url: String, + + #[arg(long, env = "GRAIN_ADMIN_USER")] + username: String, + + #[arg(long, env = "GRAIN_ADMIN_PASSWORD")] + password: String, + }, +} + +fn main() { + let cli = Cli::parse(); + + if let Err(e) = execute_command(&cli.command) { + eprintln!("Error: {}", e); + process::exit(1); + } +} + +fn execute_command(cmd: &Commands) -> Result<(), Box> { + match cmd { + Commands::User { command } => execute_user_command(command), + } +} + +fn execute_user_command(cmd: &UserCommands) -> Result<(), Box> { + let client = Client::new(); + + match cmd { + UserCommands::List { + url, + username, + password, + } => { + let response = client + .get(format!("{}/admin/users", url)) + .basic_auth(username, Some(password)) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let text = response + .text() + .unwrap_or_else(|_| String::from("No response body")); + return Err(format!("{} - {}", status, text).into()); + } + + let users: serde_json::Value = response.json()?; + println!("{}", serde_json::to_string_pretty(&users)?); + Ok(()) + } + + UserCommands::Create { + user, + pass, + url, + username, + password, + } => { + let body = json!({ + "username": user, + "password": pass, + "permissions": [] + }); + + let response = client + .post(format!("{}/admin/users", url)) + .basic_auth(username, Some(password)) + .json(&body) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let text = response + .text() + .unwrap_or_else(|_| String::from("No response body")); + return Err(format!("{} - {}", status, text).into()); + } + + println!("User '{}' created successfully", user); + Ok(()) + } + + UserCommands::Delete { + user, + url, + username, + password, + } => { + let response = client + .delete(format!("{}/admin/users/{}", url, user)) + .basic_auth(username, Some(password)) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let text = response + .text() + .unwrap_or_else(|_| String::from("No response body")); + return Err(format!("{} - {}", status, text).into()); + } + + println!("User '{}' deleted successfully", user); + Ok(()) + } + + UserCommands::AddPermission { + user, + repository, + tag, + actions, + url, + username, + password, + } => { + let actions_vec: Vec = + actions.split(',').map(|s| s.trim().to_string()).collect(); + + let body = json!({ + "repository": repository, + "tag": tag, + "actions": actions_vec + }); + + let response = client + .post(format!("{}/admin/users/{}/permissions", url, user)) + .basic_auth(username, Some(password)) + .json(&body) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let text = response + .text() + .unwrap_or_else(|_| String::from("No response body")); + return Err(format!("{} - {}", status, text).into()); + } + + println!( + "Permission added to user '{}': {} on {}:{}", + user, actions, repository, tag + ); + Ok(()) + } + } +} diff --git a/src/main.rs b/src/main.rs index 34a2dec..a3bc62c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,12 +6,16 @@ use axum::{ }; use clap::Parser; use tower_http::cors::CorsLayer; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; +mod admin; mod args; mod auth; mod blobs; mod manifests; mod meta; +mod openapi; mod permissions; mod response; mod state; @@ -72,6 +76,14 @@ async fn main() { "/v2/{org}/{repo}/blobs/{digest}", delete(blobs::delete_blob_by_digest), ) // end-10 + // Admin API routes + .route("/admin/users", get(admin::list_users)) + .route("/admin/users", post(admin::create_user)) + .route("/admin/users/{username}", delete(admin::delete_user)) + .route( + "/admin/users/{username}/permissions", + post(admin::add_permission), + ) // Catch-all routes for debugging .route("/{*path}", head(meta::catch_all_head)) .route("/{*path}", get(meta::catch_all_get)) @@ -79,8 +91,12 @@ async fn main() { .route("/{*path}", put(meta::catch_all_put)) .route("/{*path}", patch(meta::catch_all_patch)) .route("/{*path}", delete(meta::catch_all_delete)) + .with_state(shared_state) .layer(CorsLayer::permissive()) - .with_state(shared_state); + .merge( + SwaggerUi::new("/swagger-ui") + .url("/api-docs/openapi.json", openapi::AdminApiDoc::openapi()), + ); log::info!("Listening on: {}", &args.host); let listener = tokio::net::TcpListener::bind(&args.host).await.unwrap(); diff --git a/src/openapi.rs b/src/openapi.rs new file mode 100644 index 0000000..1392398 --- /dev/null +++ b/src/openapi.rs @@ -0,0 +1,61 @@ +use utoipa::OpenApi; + +use crate::{admin, state}; + +#[derive(OpenApi)] +#[openapi( + paths( + admin::list_users, + admin::create_user, + admin::delete_user, + admin::add_permission + ), + components( + schemas( + admin::CreateUserRequest, + admin::AddPermissionRequest, + state::User, + state::Permission + ) + ), + tags( + (name = "admin", description = "User and permission management endpoints") + ), + info( + title = "Grain Registry - Admin API", + version = "0.1.0", + description = "Administration API for the Grain registry. Provides endpoints for managing users and their granular tag-level permissions.", + contact( + name = "Grain Registry", + url = "https://github.com/pierrelefevre/grain" + ), + license( + name = "MIT" + ) + ), + servers( + (url = "/", description = "Local server") + ), + security( + ("basic_auth" = []) + ), + modifiers(&SecurityAddon) +)] +pub struct AdminApiDoc; + +struct SecurityAddon; + +impl utoipa::Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "basic_auth", + utoipa::openapi::security::SecurityScheme::Http( + utoipa::openapi::security::Http::new( + utoipa::openapi::security::HttpAuthScheme::Basic, + ), + ), + ); + } + } +} diff --git a/src/response.rs b/src/response.rs index 0fa279e..027b24b 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,20 +1,13 @@ use axum::{body::Body, http::Response}; -pub(crate) fn unauthorized(host: &str) -> Response { +pub(crate) fn unauthorized(host: &str) -> Response { Response::builder() .status(401) .header( "WWW-Authenticate", format!("Basic realm=\"{}\", charset=\"UTF-8\"", host), ) - .body("401 Unauthorized".to_string()) - .unwrap() -} - -pub(crate) fn ok() -> Response { - Response::builder() - .status(200) - .body("200 OK".to_string()) + .body(Body::from("401 Unauthorized")) .unwrap() } @@ -46,3 +39,21 @@ pub(crate) fn forbidden() -> Response { .body(Body::from("403 Forbidden: Insufficient permissions")) .unwrap() } + +pub(crate) fn not_found() -> Response { + Response::builder() + .status(404) + .body(Body::from("404 Not Found")) + .unwrap() +} + +pub(crate) fn no_content() -> Response { + Response::builder().status(204).body(Body::empty()).unwrap() +} + +pub(crate) fn conflict(message: &str) -> Response { + Response::builder() + .status(409) + .body(Body::from(message.to_string())) + .unwrap() +} diff --git a/src/state.rs b/src/state.rs index cc236e3..6eb08d5 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; +use utoipa::ToSchema; use std::{collections::HashSet, fmt, fs}; @@ -11,14 +12,14 @@ pub(crate) enum ServerStatus { Ready, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, ToSchema)] pub struct Permission { pub repository: String, pub tag: String, pub actions: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, ToSchema)] pub struct User { pub username: String, pub password: String, @@ -26,7 +27,7 @@ pub struct User { pub permissions: Vec, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UsersFile { pub users: Vec, }