guts_node/
auth_api.rs

1//! # Authorization API
2//!
3//! This module provides HTTP endpoints for governance and access control:
4//!
5//! - **Organizations**: Multi-user groups with role-based membership
6//! - **Teams**: Sub-groups within organizations for repository access
7//! - **Collaborators**: Direct repository access grants
8//! - **Branch Protection**: Rules enforcing code review workflows
9//! - **Webhooks**: Event notifications to external systems
10//!
11//! ## Organization Endpoints
12//!
13//! | Method | Path | Description |
14//! |--------|------|-------------|
15//! | GET | `/api/orgs` | List all organizations |
16//! | POST | `/api/orgs` | Create an organization |
17//! | GET | `/api/orgs/{org}` | Get organization details |
18//! | PATCH | `/api/orgs/{org}` | Update organization |
19//! | DELETE | `/api/orgs/{org}` | Delete organization |
20//! | GET | `/api/orgs/{org}/members` | List members |
21//! | POST | `/api/orgs/{org}/members` | Add a member |
22//! | PUT | `/api/orgs/{org}/members/{user}` | Update member role |
23//! | DELETE | `/api/orgs/{org}/members/{user}` | Remove member |
24//!
25//! ## Team Endpoints
26//!
27//! | Method | Path | Description |
28//! |--------|------|-------------|
29//! | GET | `/api/orgs/{org}/teams` | List teams in org |
30//! | POST | `/api/orgs/{org}/teams` | Create a team |
31//! | GET | `/api/orgs/{org}/teams/{team}` | Get team details |
32//! | PATCH | `/api/orgs/{org}/teams/{team}` | Update team |
33//! | DELETE | `/api/orgs/{org}/teams/{team}` | Delete team |
34//! | PUT | `/api/orgs/{org}/teams/{team}/repos/{owner}/{name}` | Grant team access to repo |
35//!
36//! ## Collaborator Endpoints
37//!
38//! | Method | Path | Description |
39//! |--------|------|-------------|
40//! | GET | `/api/repos/{owner}/{name}/collaborators` | List collaborators |
41//! | PUT | `/api/repos/{owner}/{name}/collaborators/{user}` | Add/update collaborator |
42//! | DELETE | `/api/repos/{owner}/{name}/collaborators/{user}` | Remove collaborator |
43//!
44//! ## Branch Protection Endpoints
45//!
46//! | Method | Path | Description |
47//! |--------|------|-------------|
48//! | GET | `/api/repos/{owner}/{name}/branches/{branch}/protection` | Get protection rules |
49//! | PUT | `/api/repos/{owner}/{name}/branches/{branch}/protection` | Set protection rules |
50//! | DELETE | `/api/repos/{owner}/{name}/branches/{branch}/protection` | Remove protection |
51//!
52//! ## Webhook Endpoints
53//!
54//! | Method | Path | Description |
55//! |--------|------|-------------|
56//! | GET | `/api/repos/{owner}/{name}/hooks` | List webhooks |
57//! | POST | `/api/repos/{owner}/{name}/hooks` | Create webhook |
58//! | GET | `/api/repos/{owner}/{name}/hooks/{id}` | Get webhook details |
59//! | PATCH | `/api/repos/{owner}/{name}/hooks/{id}` | Update webhook |
60//! | DELETE | `/api/repos/{owner}/{name}/hooks/{id}` | Delete webhook |
61//! | POST | `/api/repos/{owner}/{name}/hooks/{id}/ping` | Test webhook |
62//!
63//! ## Permission Resolution
64//!
65//! | Method | Path | Description |
66//! |--------|------|-------------|
67//! | GET | `/api/repos/{owner}/{name}/permission/{user}` | Check user permission |
68//!
69//! ## Permission Levels
70//!
71//! Access is controlled through hierarchical permission levels:
72//!
73//! - **Admin**: Full control (settings, access management)
74//! - **Write**: Push access (create branches, push commits)
75//! - **Read**: Clone and view access
76//!
77//! Permission resolution priority:
78//! 1. Repository owner (always Admin)
79//! 2. Direct collaborator grant
80//! 3. Team membership
81//! 4. Organization membership
82//!
83//! ## Example: Creating an Organization
84//!
85//! ```bash
86//! curl -X POST http://localhost:8080/api/orgs \
87//!   -H "Content-Type: application/json" \
88//!   -d '{"name": "acme", "display_name": "Acme Corp", "creator": "alice"}'
89//! ```
90
91use axum::{
92    extract::{Path, State},
93    http::StatusCode,
94    response::IntoResponse,
95    routing::{get, post, put},
96    Json, Router,
97};
98use guts_auth::{
99    AuthError, BranchProtection, BranchProtectionRequest, Collaborator, CreateWebhookRequest,
100    OrgMember, OrgRole, Organization, Permission, Team, UpdateWebhookRequest, Webhook,
101    WebhookEvent,
102};
103use serde::{Deserialize, Serialize};
104use std::collections::HashSet;
105
106use crate::api::AppState;
107
108/// Creates the authorization API routes.
109pub fn auth_routes() -> Router<AppState> {
110    Router::new()
111        // Organization endpoints
112        .route("/api/orgs", get(list_orgs).post(create_org))
113        .route(
114            "/api/orgs/{org}",
115            get(get_org).patch(update_org).delete(delete_org),
116        )
117        .route(
118            "/api/orgs/{org}/members",
119            get(list_org_members).post(add_org_member),
120        )
121        .route(
122            "/api/orgs/{org}/members/{user}",
123            put(update_org_member).delete(remove_org_member),
124        )
125        // Team endpoints
126        .route("/api/orgs/{org}/teams", get(list_teams).post(create_team))
127        .route(
128            "/api/orgs/{org}/teams/{team}",
129            get(get_team).patch(update_team).delete(delete_team),
130        )
131        .route(
132            "/api/orgs/{org}/teams/{team}/members",
133            get(list_team_members),
134        )
135        .route(
136            "/api/orgs/{org}/teams/{team}/members/{user}",
137            put(add_team_member).delete(remove_team_member),
138        )
139        .route("/api/orgs/{org}/teams/{team}/repos", get(list_team_repos))
140        .route(
141            "/api/orgs/{org}/teams/{team}/repos/{owner}/{name}",
142            put(add_team_repo).delete(remove_team_repo),
143        )
144        // Collaborator endpoints
145        .route(
146            "/api/repos/{owner}/{name}/collaborators",
147            get(list_collaborators),
148        )
149        .route(
150            "/api/repos/{owner}/{name}/collaborators/{user}",
151            get(get_collaborator)
152                .put(add_collaborator)
153                .delete(remove_collaborator),
154        )
155        // Branch protection endpoints
156        .route(
157            "/api/repos/{owner}/{name}/branches/{branch}/protection",
158            get(get_branch_protection)
159                .put(set_branch_protection)
160                .delete(remove_branch_protection),
161        )
162        // Webhook endpoints
163        .route(
164            "/api/repos/{owner}/{name}/hooks",
165            get(list_webhooks).post(create_webhook),
166        )
167        .route(
168            "/api/repos/{owner}/{name}/hooks/{id}",
169            get(get_webhook)
170                .patch(update_webhook)
171                .delete(delete_webhook),
172        )
173        .route(
174            "/api/repos/{owner}/{name}/hooks/{id}/ping",
175            post(ping_webhook),
176        )
177        // Permission check endpoint
178        .route(
179            "/api/repos/{owner}/{name}/permission/{user}",
180            get(check_permission),
181        )
182}
183
184// ==================== Request/Response Types ====================
185
186/// Request to create an organization.
187#[derive(Debug, Deserialize)]
188pub struct CreateOrgRequest {
189    pub name: String,
190    pub display_name: String,
191    pub description: Option<String>,
192    pub creator: String,
193}
194
195/// Request to update an organization.
196#[derive(Debug, Deserialize)]
197pub struct UpdateOrgRequest {
198    pub display_name: Option<String>,
199    pub description: Option<String>,
200}
201
202/// Response for an organization.
203#[derive(Debug, Serialize)]
204pub struct OrgResponse {
205    pub id: u64,
206    pub name: String,
207    pub display_name: String,
208    pub description: Option<String>,
209    pub created_by: String,
210    pub member_count: usize,
211    pub team_count: usize,
212    pub repo_count: usize,
213    pub created_at: u64,
214    pub updated_at: u64,
215}
216
217impl From<&Organization> for OrgResponse {
218    fn from(org: &Organization) -> Self {
219        Self {
220            id: org.id,
221            name: org.name.clone(),
222            display_name: org.display_name.clone(),
223            description: org.description.clone(),
224            created_by: org.created_by.clone(),
225            member_count: org.members.len(),
226            team_count: org.teams.len(),
227            repo_count: org.repos.len(),
228            created_at: org.created_at,
229            updated_at: org.updated_at,
230        }
231    }
232}
233
234/// Request to add an organization member.
235#[derive(Debug, Deserialize)]
236pub struct AddOrgMemberRequest {
237    pub user: String,
238    pub role: String,
239    pub added_by: String,
240}
241
242/// Response for an organization member.
243#[derive(Debug, Serialize)]
244pub struct OrgMemberResponse {
245    pub user: String,
246    pub role: String,
247    pub added_at: u64,
248    pub added_by: String,
249}
250
251impl From<&OrgMember> for OrgMemberResponse {
252    fn from(m: &OrgMember) -> Self {
253        Self {
254            user: m.user.clone(),
255            role: m.role.to_string(),
256            added_at: m.added_at,
257            added_by: m.added_by.clone(),
258        }
259    }
260}
261
262/// Request to create a team.
263#[derive(Debug, Deserialize)]
264pub struct CreateTeamRequest {
265    pub name: String,
266    pub description: Option<String>,
267    pub permission: String,
268    pub created_by: String,
269}
270
271/// Request to update a team.
272#[derive(Debug, Deserialize)]
273pub struct UpdateTeamRequest {
274    pub name: Option<String>,
275    pub description: Option<String>,
276    pub permission: Option<String>,
277}
278
279/// Response for a team.
280#[derive(Debug, Serialize)]
281pub struct TeamResponse {
282    pub id: u64,
283    pub org_id: u64,
284    pub name: String,
285    pub description: Option<String>,
286    pub permission: String,
287    pub member_count: usize,
288    pub repo_count: usize,
289    pub created_at: u64,
290    pub updated_at: u64,
291}
292
293impl From<&Team> for TeamResponse {
294    fn from(t: &Team) -> Self {
295        Self {
296            id: t.id,
297            org_id: t.org_id,
298            name: t.name.clone(),
299            description: t.description.clone(),
300            permission: t.permission.to_string(),
301            member_count: t.members.len(),
302            repo_count: t.repos.len(),
303            created_at: t.created_at,
304            updated_at: t.updated_at,
305        }
306    }
307}
308
309/// Request to add a collaborator.
310#[derive(Debug, Deserialize)]
311pub struct AddCollaboratorRequest {
312    pub permission: String,
313    pub added_by: String,
314}
315
316/// Response for a collaborator.
317#[derive(Debug, Serialize)]
318pub struct CollaboratorResponse {
319    pub user: String,
320    pub permission: String,
321    pub added_by: String,
322    pub created_at: u64,
323}
324
325impl From<&Collaborator> for CollaboratorResponse {
326    fn from(c: &Collaborator) -> Self {
327        Self {
328            user: c.user.clone(),
329            permission: c.permission.to_string(),
330            added_by: c.added_by.clone(),
331            created_at: c.created_at,
332        }
333    }
334}
335
336/// Response for branch protection.
337#[derive(Debug, Serialize)]
338pub struct BranchProtectionResponse {
339    pub id: u64,
340    pub pattern: String,
341    pub require_pr: bool,
342    pub required_reviews: u32,
343    pub required_status_checks: Vec<String>,
344    pub dismiss_stale_reviews: bool,
345    pub require_code_owner_review: bool,
346    pub restrict_pushes: bool,
347    pub allow_force_push: bool,
348    pub allow_deletion: bool,
349    pub created_at: u64,
350    pub updated_at: u64,
351}
352
353impl From<&BranchProtection> for BranchProtectionResponse {
354    fn from(bp: &BranchProtection) -> Self {
355        Self {
356            id: bp.id,
357            pattern: bp.pattern.clone(),
358            require_pr: bp.require_pr,
359            required_reviews: bp.required_reviews,
360            required_status_checks: bp.required_status_checks.iter().cloned().collect(),
361            dismiss_stale_reviews: bp.dismiss_stale_reviews,
362            require_code_owner_review: bp.require_code_owner_review,
363            restrict_pushes: bp.restrict_pushes,
364            allow_force_push: bp.allow_force_push,
365            allow_deletion: bp.allow_deletion,
366            created_at: bp.created_at,
367            updated_at: bp.updated_at,
368        }
369    }
370}
371
372/// Response for a webhook.
373#[derive(Debug, Serialize)]
374pub struct WebhookResponse {
375    pub id: u64,
376    pub url: String,
377    pub events: Vec<String>,
378    pub active: bool,
379    pub content_type: String,
380    pub delivery_count: u64,
381    pub failure_count: u64,
382    pub created_at: u64,
383    pub updated_at: u64,
384}
385
386impl From<&Webhook> for WebhookResponse {
387    fn from(w: &Webhook) -> Self {
388        Self {
389            id: w.id,
390            url: w.url.clone(),
391            events: w.events.iter().map(|e| e.to_string()).collect(),
392            active: w.active,
393            content_type: w.content_type.clone(),
394            delivery_count: w.delivery_count,
395            failure_count: w.failure_count,
396            created_at: w.created_at,
397            updated_at: w.updated_at,
398        }
399    }
400}
401
402/// Response for permission check.
403#[derive(Debug, Serialize)]
404pub struct PermissionResponse {
405    pub user: String,
406    pub permission: Option<String>,
407    pub has_access: bool,
408}
409
410/// Error response.
411#[derive(Debug, Serialize)]
412struct ErrorResponse {
413    error: String,
414}
415
416/// Wrapper for auth errors.
417struct AuthApiError(AuthError);
418
419impl From<AuthError> for AuthApiError {
420    fn from(err: AuthError) -> Self {
421        Self(err)
422    }
423}
424
425impl IntoResponse for AuthApiError {
426    fn into_response(self) -> axum::response::Response {
427        let (status, message) = match &self.0 {
428            AuthError::NotFound(_) => (StatusCode::NOT_FOUND, self.0.to_string()),
429            AuthError::PermissionDenied(_) => (StatusCode::FORBIDDEN, self.0.to_string()),
430            AuthError::AlreadyExists(_) => (StatusCode::CONFLICT, self.0.to_string()),
431            AuthError::InvalidInput(_) => (StatusCode::BAD_REQUEST, self.0.to_string()),
432            AuthError::LastOwner => (StatusCode::BAD_REQUEST, self.0.to_string()),
433            AuthError::BranchProtected(_, _) => (StatusCode::FORBIDDEN, self.0.to_string()),
434            AuthError::InvalidWebhook(_) => (StatusCode::BAD_REQUEST, self.0.to_string()),
435            AuthError::Serialization(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()),
436        };
437
438        (status, Json(ErrorResponse { error: message })).into_response()
439    }
440}
441
442// ==================== Organization Handlers ====================
443
444/// Lists all organizations.
445async fn list_orgs(State(state): State<AppState>) -> impl IntoResponse {
446    let orgs = state.auth.list_organizations();
447    let responses: Vec<OrgResponse> = orgs.iter().map(Into::into).collect();
448    Json(responses)
449}
450
451/// Creates a new organization.
452async fn create_org(
453    State(state): State<AppState>,
454    Json(req): Json<CreateOrgRequest>,
455) -> Result<impl IntoResponse, AuthApiError> {
456    let mut org = state
457        .auth
458        .create_organization(req.name, req.display_name, req.creator)?;
459
460    if let Some(desc) = req.description {
461        org = state.auth.update_organization(org.id, None, Some(desc))?;
462    }
463
464    Ok((StatusCode::CREATED, Json(OrgResponse::from(&org))))
465}
466
467/// Gets an organization by name.
468async fn get_org(
469    State(state): State<AppState>,
470    Path(org_name): Path<String>,
471) -> Result<impl IntoResponse, AuthApiError> {
472    let org = state
473        .auth
474        .get_organization_by_name(&org_name)
475        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
476
477    Ok(Json(OrgResponse::from(&org)))
478}
479
480/// Updates an organization.
481async fn update_org(
482    State(state): State<AppState>,
483    Path(org_name): Path<String>,
484    Json(req): Json<UpdateOrgRequest>,
485) -> Result<impl IntoResponse, AuthApiError> {
486    let org = state
487        .auth
488        .get_organization_by_name(&org_name)
489        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
490
491    let updated = state
492        .auth
493        .update_organization(org.id, req.display_name, req.description)?;
494
495    Ok(Json(OrgResponse::from(&updated)))
496}
497
498/// Deletes an organization.
499async fn delete_org(
500    State(state): State<AppState>,
501    Path(org_name): Path<String>,
502) -> Result<impl IntoResponse, AuthApiError> {
503    let org = state
504        .auth
505        .get_organization_by_name(&org_name)
506        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
507
508    state.auth.delete_organization(org.id)?;
509
510    Ok(StatusCode::NO_CONTENT)
511}
512
513/// Lists organization members.
514async fn list_org_members(
515    State(state): State<AppState>,
516    Path(org_name): Path<String>,
517) -> Result<impl IntoResponse, AuthApiError> {
518    let org = state
519        .auth
520        .get_organization_by_name(&org_name)
521        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
522
523    let responses: Vec<OrgMemberResponse> = org.members.iter().map(Into::into).collect();
524
525    Ok(Json(responses))
526}
527
528/// Adds a member to an organization.
529async fn add_org_member(
530    State(state): State<AppState>,
531    Path(org_name): Path<String>,
532    Json(req): Json<AddOrgMemberRequest>,
533) -> Result<impl IntoResponse, AuthApiError> {
534    let org = state
535        .auth
536        .get_organization_by_name(&org_name)
537        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
538
539    let role = OrgRole::parse(&req.role)
540        .ok_or_else(|| AuthError::InvalidInput(format!("invalid role: {}", req.role)))?;
541
542    let member = OrgMember::new(req.user, role, req.added_by);
543    state.auth.add_org_member(org.id, member.clone())?;
544
545    Ok((StatusCode::CREATED, Json(OrgMemberResponse::from(&member))))
546}
547
548/// Updates a member's role.
549async fn update_org_member(
550    State(state): State<AppState>,
551    Path((org_name, user)): Path<(String, String)>,
552    Json(req): Json<AddOrgMemberRequest>,
553) -> Result<impl IntoResponse, AuthApiError> {
554    let org = state
555        .auth
556        .get_organization_by_name(&org_name)
557        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
558
559    let role = OrgRole::parse(&req.role)
560        .ok_or_else(|| AuthError::InvalidInput(format!("invalid role: {}", req.role)))?;
561
562    state.auth.update_org_member_role(org.id, &user, role)?;
563
564    // Get updated org
565    let org = state.auth.get_organization(org.id).unwrap();
566    let member = org
567        .get_member(&user)
568        .ok_or_else(|| AuthError::NotFound(format!("member '{}'", user)))?;
569
570    Ok(Json(OrgMemberResponse::from(member)))
571}
572
573/// Removes a member from an organization.
574async fn remove_org_member(
575    State(state): State<AppState>,
576    Path((org_name, user)): Path<(String, String)>,
577) -> Result<impl IntoResponse, AuthApiError> {
578    let org = state
579        .auth
580        .get_organization_by_name(&org_name)
581        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
582
583    state.auth.remove_org_member(org.id, &user)?;
584
585    Ok(StatusCode::NO_CONTENT)
586}
587
588// ==================== Team Handlers ====================
589
590/// Lists teams in an organization.
591async fn list_teams(
592    State(state): State<AppState>,
593    Path(org_name): Path<String>,
594) -> Result<impl IntoResponse, AuthApiError> {
595    let org = state
596        .auth
597        .get_organization_by_name(&org_name)
598        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
599
600    let teams = state.auth.list_teams(org.id);
601    let responses: Vec<TeamResponse> = teams.iter().map(Into::into).collect();
602
603    Ok(Json(responses))
604}
605
606/// Creates a new team.
607async fn create_team(
608    State(state): State<AppState>,
609    Path(org_name): Path<String>,
610    Json(req): Json<CreateTeamRequest>,
611) -> Result<impl IntoResponse, AuthApiError> {
612    let org = state
613        .auth
614        .get_organization_by_name(&org_name)
615        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
616
617    let permission = Permission::parse(&req.permission).ok_or_else(|| {
618        AuthError::InvalidInput(format!("invalid permission: {}", req.permission))
619    })?;
620
621    let mut team = state
622        .auth
623        .create_team(org.id, req.name, permission, req.created_by)?;
624
625    if let Some(desc) = req.description {
626        team = state.auth.update_team(team.id, None, Some(desc), None)?;
627    }
628
629    Ok((StatusCode::CREATED, Json(TeamResponse::from(&team))))
630}
631
632/// Gets a team by name.
633async fn get_team(
634    State(state): State<AppState>,
635    Path((org_name, team_name)): Path<(String, String)>,
636) -> Result<impl IntoResponse, AuthApiError> {
637    let org = state
638        .auth
639        .get_organization_by_name(&org_name)
640        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
641
642    let team = state
643        .auth
644        .get_team_by_name(org.id, &team_name)
645        .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
646
647    Ok(Json(TeamResponse::from(&team)))
648}
649
650/// Updates a team.
651async fn update_team(
652    State(state): State<AppState>,
653    Path((org_name, team_name)): Path<(String, String)>,
654    Json(req): Json<UpdateTeamRequest>,
655) -> Result<impl IntoResponse, AuthApiError> {
656    let org = state
657        .auth
658        .get_organization_by_name(&org_name)
659        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
660
661    let team = state
662        .auth
663        .get_team_by_name(org.id, &team_name)
664        .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
665
666    let permission = req
667        .permission
668        .as_ref()
669        .map(|p| {
670            Permission::parse(p)
671                .ok_or_else(|| AuthError::InvalidInput(format!("invalid permission: {}", p)))
672        })
673        .transpose()?;
674
675    let updated = state
676        .auth
677        .update_team(team.id, req.name, req.description, permission)?;
678
679    Ok(Json(TeamResponse::from(&updated)))
680}
681
682/// Deletes a team.
683async fn delete_team(
684    State(state): State<AppState>,
685    Path((org_name, team_name)): Path<(String, String)>,
686) -> Result<impl IntoResponse, AuthApiError> {
687    let org = state
688        .auth
689        .get_organization_by_name(&org_name)
690        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
691
692    let team = state
693        .auth
694        .get_team_by_name(org.id, &team_name)
695        .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
696
697    state.auth.delete_team(team.id)?;
698
699    Ok(StatusCode::NO_CONTENT)
700}
701
702/// Lists team members.
703async fn list_team_members(
704    State(state): State<AppState>,
705    Path((org_name, team_name)): Path<(String, String)>,
706) -> Result<impl IntoResponse, AuthApiError> {
707    let org = state
708        .auth
709        .get_organization_by_name(&org_name)
710        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
711
712    let team = state
713        .auth
714        .get_team_by_name(org.id, &team_name)
715        .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
716
717    let members: Vec<String> = team.members.iter().cloned().collect();
718
719    Ok(Json(members))
720}
721
722/// Adds a member to a team.
723async fn add_team_member(
724    State(state): State<AppState>,
725    Path((org_name, team_name, user)): Path<(String, String, String)>,
726) -> Result<impl IntoResponse, AuthApiError> {
727    let org = state
728        .auth
729        .get_organization_by_name(&org_name)
730        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
731
732    let team = state
733        .auth
734        .get_team_by_name(org.id, &team_name)
735        .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
736
737    state.auth.add_team_member(team.id, user)?;
738
739    Ok(StatusCode::NO_CONTENT)
740}
741
742/// Removes a member from a team.
743async fn remove_team_member(
744    State(state): State<AppState>,
745    Path((org_name, team_name, user)): Path<(String, String, String)>,
746) -> Result<impl IntoResponse, AuthApiError> {
747    let org = state
748        .auth
749        .get_organization_by_name(&org_name)
750        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
751
752    let team = state
753        .auth
754        .get_team_by_name(org.id, &team_name)
755        .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
756
757    state.auth.remove_team_member(team.id, &user)?;
758
759    Ok(StatusCode::NO_CONTENT)
760}
761
762/// Lists repositories in a team.
763async fn list_team_repos(
764    State(state): State<AppState>,
765    Path((org_name, team_name)): Path<(String, String)>,
766) -> Result<impl IntoResponse, AuthApiError> {
767    let org = state
768        .auth
769        .get_organization_by_name(&org_name)
770        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
771
772    let team = state
773        .auth
774        .get_team_by_name(org.id, &team_name)
775        .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
776
777    let repos: Vec<String> = team.repos.iter().cloned().collect();
778
779    Ok(Json(repos))
780}
781
782/// Adds a repository to a team.
783async fn add_team_repo(
784    State(state): State<AppState>,
785    Path((org_name, team_name, owner, name)): Path<(String, String, String, String)>,
786) -> Result<impl IntoResponse, AuthApiError> {
787    let org = state
788        .auth
789        .get_organization_by_name(&org_name)
790        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
791
792    let team = state
793        .auth
794        .get_team_by_name(org.id, &team_name)
795        .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
796
797    let repo_key = format!("{}/{}", owner, name);
798    state.auth.add_team_repo(team.id, repo_key)?;
799
800    Ok(StatusCode::NO_CONTENT)
801}
802
803/// Removes a repository from a team.
804async fn remove_team_repo(
805    State(state): State<AppState>,
806    Path((org_name, team_name, owner, name)): Path<(String, String, String, String)>,
807) -> Result<impl IntoResponse, AuthApiError> {
808    let org = state
809        .auth
810        .get_organization_by_name(&org_name)
811        .ok_or_else(|| AuthError::NotFound(format!("organization '{}'", org_name)))?;
812
813    let team = state
814        .auth
815        .get_team_by_name(org.id, &team_name)
816        .ok_or_else(|| AuthError::NotFound(format!("team '{}'", team_name)))?;
817
818    let repo_key = format!("{}/{}", owner, name);
819    state.auth.remove_team_repo(team.id, &repo_key)?;
820
821    Ok(StatusCode::NO_CONTENT)
822}
823
824// ==================== Collaborator Handlers ====================
825
826/// Lists collaborators for a repository.
827async fn list_collaborators(
828    State(state): State<AppState>,
829    Path((owner, name)): Path<(String, String)>,
830) -> impl IntoResponse {
831    let repo_key = format!("{}/{}", owner, name);
832    let collaborators = state.auth.list_collaborators(&repo_key);
833    let responses: Vec<CollaboratorResponse> = collaborators.iter().map(Into::into).collect();
834    Json(responses)
835}
836
837/// Gets a collaborator.
838async fn get_collaborator(
839    State(state): State<AppState>,
840    Path((owner, name, user)): Path<(String, String, String)>,
841) -> Result<impl IntoResponse, AuthApiError> {
842    let repo_key = format!("{}/{}", owner, name);
843    let collaborator = state
844        .auth
845        .get_collaborator(&repo_key, &user)
846        .ok_or_else(|| AuthError::NotFound(format!("collaborator '{}' on '{}'", user, repo_key)))?;
847
848    Ok(Json(CollaboratorResponse::from(&collaborator)))
849}
850
851/// Adds or updates a collaborator.
852async fn add_collaborator(
853    State(state): State<AppState>,
854    Path((owner, name, user)): Path<(String, String, String)>,
855    Json(req): Json<AddCollaboratorRequest>,
856) -> Result<impl IntoResponse, AuthApiError> {
857    let repo_key = format!("{}/{}", owner, name);
858    let permission = Permission::parse(&req.permission).ok_or_else(|| {
859        AuthError::InvalidInput(format!("invalid permission: {}", req.permission))
860    })?;
861
862    let collaborator = state
863        .auth
864        .set_collaborator(repo_key, user, permission, req.added_by);
865
866    Ok((
867        StatusCode::CREATED,
868        Json(CollaboratorResponse::from(&collaborator)),
869    ))
870}
871
872/// Removes a collaborator.
873async fn remove_collaborator(
874    State(state): State<AppState>,
875    Path((owner, name, user)): Path<(String, String, String)>,
876) -> Result<impl IntoResponse, AuthApiError> {
877    let repo_key = format!("{}/{}", owner, name);
878    state.auth.remove_collaborator(&repo_key, &user)?;
879
880    Ok(StatusCode::NO_CONTENT)
881}
882
883// ==================== Branch Protection Handlers ====================
884
885/// Gets branch protection for a branch.
886async fn get_branch_protection(
887    State(state): State<AppState>,
888    Path((owner, name, branch)): Path<(String, String, String)>,
889) -> Result<impl IntoResponse, AuthApiError> {
890    let repo_key = format!("{}/{}", owner, name);
891    let protection = state
892        .auth
893        .find_branch_protection(&repo_key, &branch)
894        .ok_or_else(|| {
895            AuthError::NotFound(format!(
896                "branch protection for '{}' on '{}'",
897                branch, repo_key
898            ))
899        })?;
900
901    Ok(Json(BranchProtectionResponse::from(&protection)))
902}
903
904/// Sets branch protection for a branch.
905async fn set_branch_protection(
906    State(state): State<AppState>,
907    Path((owner, name, branch)): Path<(String, String, String)>,
908    Json(req): Json<BranchProtectionRequest>,
909) -> Result<impl IntoResponse, AuthApiError> {
910    let repo_key = format!("{}/{}", owner, name);
911
912    // Create or get existing protection
913    let protection = state
914        .auth
915        .set_branch_protection(repo_key.clone(), branch.clone());
916
917    // Update with request values
918    let updated = state
919        .auth
920        .update_branch_protection(&repo_key, &branch, |p| {
921            p.require_pr = req.require_pr;
922            p.required_reviews = req.required_reviews;
923            p.required_status_checks = req.required_status_checks.iter().cloned().collect();
924            p.dismiss_stale_reviews = req.dismiss_stale_reviews;
925            p.require_code_owner_review = req.require_code_owner_review;
926            p.restrict_pushes = req.restrict_pushes;
927            p.allow_force_push = req.allow_force_push;
928            p.allow_deletion = req.allow_deletion;
929        })?;
930
931    // Return created status if this was a new protection
932    let status = if updated.id == protection.id {
933        StatusCode::OK
934    } else {
935        StatusCode::CREATED
936    };
937
938    Ok((status, Json(BranchProtectionResponse::from(&updated))))
939}
940
941/// Removes branch protection for a branch.
942async fn remove_branch_protection(
943    State(state): State<AppState>,
944    Path((owner, name, branch)): Path<(String, String, String)>,
945) -> Result<impl IntoResponse, AuthApiError> {
946    let repo_key = format!("{}/{}", owner, name);
947    state.auth.remove_branch_protection(&repo_key, &branch)?;
948
949    Ok(StatusCode::NO_CONTENT)
950}
951
952// ==================== Webhook Handlers ====================
953
954/// Lists webhooks for a repository.
955async fn list_webhooks(
956    State(state): State<AppState>,
957    Path((owner, name)): Path<(String, String)>,
958) -> impl IntoResponse {
959    let repo_key = format!("{}/{}", owner, name);
960    let webhooks = state.auth.list_webhooks(&repo_key);
961    let responses: Vec<WebhookResponse> = webhooks.iter().map(Into::into).collect();
962    Json(responses)
963}
964
965/// Creates a webhook.
966async fn create_webhook(
967    State(state): State<AppState>,
968    Path((owner, name)): Path<(String, String)>,
969    Json(req): Json<CreateWebhookRequest>,
970) -> Result<impl IntoResponse, AuthApiError> {
971    let repo_key = format!("{}/{}", owner, name);
972
973    // Parse events
974    let events: HashSet<WebhookEvent> = req
975        .events
976        .iter()
977        .filter_map(|e| WebhookEvent::parse(e))
978        .collect();
979
980    if events.is_empty() {
981        return Err(AuthError::InvalidInput("at least one valid event is required".into()).into());
982    }
983
984    let mut webhook = state.auth.create_webhook(repo_key, req.url, events);
985
986    // Set secret if provided
987    if let Some(secret) = req.secret {
988        state
989            .auth
990            .update_webhook(webhook.id, |w| {
991                w.secret = Some(secret);
992            })
993            .ok();
994        webhook = state.auth.get_webhook(webhook.id).unwrap();
995    }
996
997    Ok((StatusCode::CREATED, Json(WebhookResponse::from(&webhook))))
998}
999
1000/// Gets a webhook by ID.
1001async fn get_webhook(
1002    State(state): State<AppState>,
1003    Path((owner, name, id)): Path<(String, String, u64)>,
1004) -> Result<impl IntoResponse, AuthApiError> {
1005    let repo_key = format!("{}/{}", owner, name);
1006    let webhook = state
1007        .auth
1008        .get_webhook(id)
1009        .filter(|w| w.repo_key == repo_key)
1010        .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
1011
1012    Ok(Json(WebhookResponse::from(&webhook)))
1013}
1014
1015/// Updates a webhook.
1016async fn update_webhook(
1017    State(state): State<AppState>,
1018    Path((owner, name, id)): Path<(String, String, u64)>,
1019    Json(req): Json<UpdateWebhookRequest>,
1020) -> Result<impl IntoResponse, AuthApiError> {
1021    let repo_key = format!("{}/{}", owner, name);
1022
1023    // Verify webhook belongs to this repo
1024    let webhook = state
1025        .auth
1026        .get_webhook(id)
1027        .filter(|w| w.repo_key == repo_key)
1028        .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
1029
1030    let updated = state.auth.update_webhook(webhook.id, |w| {
1031        if let Some(url) = &req.url {
1032            w.url = url.clone();
1033        }
1034        if let Some(secret) = &req.secret {
1035            w.secret = Some(secret.clone());
1036        }
1037        if let Some(events) = &req.events {
1038            w.events = events
1039                .iter()
1040                .filter_map(|e| WebhookEvent::parse(e))
1041                .collect();
1042        }
1043        if let Some(active) = req.active {
1044            w.active = active;
1045        }
1046    })?;
1047
1048    Ok(Json(WebhookResponse::from(&updated)))
1049}
1050
1051/// Deletes a webhook.
1052async fn delete_webhook(
1053    State(state): State<AppState>,
1054    Path((owner, name, id)): Path<(String, String, u64)>,
1055) -> Result<impl IntoResponse, AuthApiError> {
1056    let repo_key = format!("{}/{}", owner, name);
1057
1058    // Verify webhook belongs to this repo
1059    let _webhook = state
1060        .auth
1061        .get_webhook(id)
1062        .filter(|w| w.repo_key == repo_key)
1063        .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
1064
1065    state.auth.delete_webhook(id)?;
1066
1067    Ok(StatusCode::NO_CONTENT)
1068}
1069
1070/// Pings a webhook (for testing).
1071async fn ping_webhook(
1072    State(state): State<AppState>,
1073    Path((owner, name, id)): Path<(String, String, u64)>,
1074) -> Result<impl IntoResponse, AuthApiError> {
1075    let repo_key = format!("{}/{}", owner, name);
1076
1077    // Verify webhook belongs to this repo
1078    let webhook = state
1079        .auth
1080        .get_webhook(id)
1081        .filter(|w| w.repo_key == repo_key)
1082        .ok_or_else(|| AuthError::NotFound(format!("webhook {}", id)))?;
1083
1084    // In a real implementation, this would send a ping request to the webhook URL
1085    // For now, just return success
1086    Ok(Json(serde_json::json!({
1087        "id": webhook.id,
1088        "url": webhook.url,
1089        "message": "Ping sent successfully"
1090    })))
1091}
1092
1093// ==================== Permission Check Handler ====================
1094
1095/// Checks a user's permission on a repository.
1096async fn check_permission(
1097    State(state): State<AppState>,
1098    Path((owner, name, user)): Path<(String, String, String)>,
1099) -> impl IntoResponse {
1100    let repo_key = format!("{}/{}", owner, name);
1101    let permission = state.auth.get_effective_permission(&user, &repo_key);
1102
1103    Json(PermissionResponse {
1104        user,
1105        permission: permission.map(|p| p.to_string()),
1106        has_access: permission.is_some(),
1107    })
1108}