Skip to content
Clone

feat(gitauth): add gitauth crate (auth, permission, service)

zhenyi committed
parent bc84941 commit 56d54bb Browse files

8 files changed

+889 −0

File tree 8
Added lib/gitauth/basic.rs View lib/gitauth/basic.rs
@@ -0,0 +1,72 @@
1use base64::Engine;
2use base64::engine::general_purpose::STANDARD;
3
4use crate::error::{GitAuthError, GitAuthResult};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct BasicCredentials {
8 pub username: String,
9 pub password: String,
10}
11
12/// Parse an `Authorization: Basic <base64>` header value.
13pub fn parse_authorization(header: &str) -> GitAuthResult<BasicCredentials> {
14 let encoded = header.strip_prefix("Basic ").ok_or_else(|| {
15 GitAuthError::BadRequest("expected 'Basic <credentials>' authorization header".into())
16 })?;
17 let decoded = STANDARD
18 .decode(encoded.trim())
19 .map_err(|_| GitAuthError::BadRequest("basic credentials are not valid base64".into()))?;
20 let text = String::from_utf8(decoded)
21 .map_err(|_| GitAuthError::BadRequest("basic credentials are not valid utf-8".into()))?;
22 let (username, password) = text.split_once(':').ok_or_else(|| {
23 GitAuthError::BadRequest("basic credentials missing ':' separator".into())
24 })?;
25 Ok(BasicCredentials {
26 username: username.to_string(),
27 password: password.to_string(),
28 })
29}
30
31/// Build a `Basic <base64>` header value (used by clients and tests).
32pub fn encode_credentials(username: &str, password: &str) -> String {
33 format!(
34 "Basic {}",
35 STANDARD.encode(format!("{username}:{password}"))
36 )
37}
38
39#[cfg(test)]
40mod tests {
41 use super::*;
42
43 #[test]
44 fn parses_valid_header() {
45 let header = encode_credentials("alice", "s3cret");
46 let creds = parse_authorization(&header).expect("parse");
47 assert_eq!(
48 creds,
49 BasicCredentials {
50 username: "alice".into(),
51 password: "s3cret".into(),
52 }
53 );
54 }
55
56 #[test]
57 fn rejects_missing_scheme() {
58 assert!(parse_authorization("Bearer abc").is_err());
59 }
60
61 #[test]
62 fn rejects_invalid_base64() {
63 assert!(parse_authorization("Basic !!!not-base64!!!").is_err());
64 }
65
66 #[test]
67 fn rejects_missing_separator() {
68 let header = encode_credentials("onlyuser", "");
69 let creds = parse_authorization(&header).expect("parse");
70 assert_eq!(creds.password, "");
71 }
72}
Added lib/gitauth/config.rs View lib/gitauth/config.rs
@@ -0,0 +1,14 @@
1#[derive(Debug, Clone)]
2pub struct GitAuthConfig {
3 /// Whether anonymous clients may read public repositories.
4 /// When false, every git smart HTTP request requires authentication.
5 pub allow_anonymous_read: bool,
6}
7
8impl Default for GitAuthConfig {
9 fn default() -> Self {
10 Self {
11 allow_anonymous_read: true,
12 }
13 }
14}
Added lib/gitauth/error.rs View lib/gitauth/error.rs
@@ -0,0 +1,29 @@
1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum GitAuthError {
5 #[error("{0}")]
6 BadRequest(String),
7 #[error("{0}")]
8 Unauthorized(String),
9 #[error("{0}")]
10 Forbidden(String),
11 #[error("{0}")]
12 NotFound(String),
13 #[error("{0}")]
14 Conflict(String),
15 #[error("{0}")]
16 Internal(String),
17 #[error(transparent)]
18 Db(#[from] db::conn::SqlxError),
19 #[error(transparent)]
20 Jwt(#[from] jwt::JwtError),
21 #[error(transparent)]
22 Auth(#[from] auth::AuthError),
23 #[error(transparent)]
24 Uuid(#[from] uuid::Error),
25 #[error(transparent)]
26 Base64(#[from] base64::DecodeError),
27}
28
29pub type GitAuthResult<T> = Result<T, GitAuthError>;
Added lib/gitauth/lib.rs View lib/gitauth/lib.rs
@@ -0,0 +1,15 @@
1pub mod basic;
2pub mod config;
3pub mod error;
4pub mod permission;
5pub mod resolve;
6pub mod service;
7
8#[cfg(test)]
9pub mod testing;
10
11pub use basic::{BasicCredentials, parse_authorization};
12pub use config::GitAuthConfig;
13pub use error::{GitAuthError, GitAuthResult};
14pub use resolve::ResolvedRepo;
15pub use service::{AuthenticatedUser, GitAuthService};
Added lib/gitauth/permission.rs View lib/gitauth/permission.rs
@@ -0,0 +1,137 @@
1use db::channel::channel_members;
2use db::conn::sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
3use db::repo::repo_collaborations;
4use db::space::{space_member_roles, space_members, space_role_permissions, spaces};
5use repo::actor::{ActorContext, ChannelRole, RepoAccess, SpaceRole};
6use uuid::Uuid;
7
8use crate::error::{GitAuthError, GitAuthResult};
9use crate::resolve::ResolvedRepo;
10
11/// Space-level permission that marks a member as an administrator of the
12/// space (mirrors `space::features::authz::MANAGE_MEMBERS`).
13pub const SPACE_ADMIN_PERMISSION: &str = "manage_members";
14
15pub fn channel_role_for(member: Option<&channel_members::Model>) -> Option<ChannelRole> {
16 member.map(|m| match m.role {
17 channel_members::ChannelMemberRole::Member => ChannelRole::Member,
18 channel_members::ChannelMemberRole::Admin => ChannelRole::Moderator,
19 channel_members::ChannelMemberRole::Owner => ChannelRole::Owner,
20 })
21}
22
23pub async fn space_role_for(
24 db: &impl ConnectionTrait,
25 space: &spaces::Model,
26 user_id: Uuid,
27) -> GitAuthResult<Option<SpaceRole>> {
28 if space.owner_id == user_id {
29 return Ok(Some(SpaceRole::Owner));
30 }
31 let Some(member) = space_members::Entity::find_by_id((space.id, user_id))
32 .one(db)
33 .await?
34 else {
35 return Ok(None);
36 };
37 if member.status != space_members::MemberStatus::Active {
38 return Ok(None);
39 }
40 let role_ids: Vec<Uuid> = space_member_roles::Entity::find()
41 .filter(space_member_roles::Column::SpaceId.eq(space.id))
42 .filter(space_member_roles::Column::UserId.eq(user_id))
43 .all(db)
44 .await?
45 .into_iter()
46 .map(|m| m.role_id)
47 .collect();
48 let is_admin = if role_ids.is_empty() {
49 false
50 } else {
51 let perms = space_role_permissions::Entity::find()
52 .filter(space_role_permissions::Column::RoleId.is_in(role_ids))
53 .all(db)
54 .await?;
55 perms.iter().any(|p| p.permission == SPACE_ADMIN_PERMISSION)
56 };
57 Ok(Some(if is_admin {
58 SpaceRole::Admin
59 } else {
60 SpaceRole::Member
61 }))
62}
63
64pub async fn collaborator_access(
65 db: &impl ConnectionTrait,
66 repo_id: Uuid,
67 user_id: Uuid,
68) -> GitAuthResult<Option<RepoAccess>> {
69 let Some(collab) = repo_collaborations::Entity::find()
70 .filter(repo_collaborations::Column::RepoId.eq(repo_id))
71 .filter(repo_collaborations::Column::UserId.eq(user_id))
72 .one(db)
73 .await?
74 else {
75 return Ok(None);
76 };
77 Ok(Some(match collab.mode {
78 repo_collaborations::CollaborationMode::Read => RepoAccess::Read,
79 repo_collaborations::CollaborationMode::Write => RepoAccess::Write,
80 repo_collaborations::CollaborationMode::Admin => RepoAccess::Admin,
81 }))
82}
83
84/// Enforce read/write access for `actor` against a resolved repository,
85/// delegating role grading to `repo::permission::effective_access`.
86pub async fn check_access(
87 db: &impl ConnectionTrait,
88 actor: &ActorContext,
89 resolved: &ResolvedRepo,
90 required: RepoAccess,
91 allow_anonymous_read: bool,
92) -> GitAuthResult<()> {
93 if !allow_anonymous_read && !actor.is_authenticated() {
94 return Err(GitAuthError::Unauthorized("authentication required".into()));
95 }
96
97 let (channel_role, space_role, collaborator) = match actor.user_id {
98 Some(user_id) => {
99 let member = channel_members::Entity::find_by_id((resolved.repo.channel_id, user_id))
100 .one(db)
101 .await?;
102 let channel_role = channel_role_for(member.as_ref());
103 let space_role = if resolved.channel.space_id.is_some() {
104 space_role_for(db, &resolved.space, user_id).await?
105 } else {
106 None
107 };
108 let collaborator = collaborator_access(db, resolved.repo.id, user_id).await?;
109 (channel_role, space_role, collaborator)
110 }
111 None => (None, None, None),
112 };
113
114 repo::permission::require(
115 actor,
116 required,
117 channel_role,
118 space_role,
119 collaborator,
120 !resolved.repo.is_private,
121 )
122 .map_err(map_permission_error)
123}
124
125fn map_permission_error(e: repo::permission::PermissionError) -> GitAuthError {
126 match e {
127 repo::permission::PermissionError::Anonymous => {
128 GitAuthError::Unauthorized("authentication required".into())
129 }
130 repo::permission::PermissionError::Insufficient => {
131 GitAuthError::Forbidden("insufficient permissions".into())
132 }
133 repo::permission::PermissionError::Forbidden => {
134 GitAuthError::Forbidden("access forbidden".into())
135 }
136 }
137}
Added lib/gitauth/resolve.rs View lib/gitauth/resolve.rs
@@ -0,0 +1,74 @@
1use db::channel::channels;
2use db::conn::sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
3use db::repo::repos;
4use db::space::spaces;
5use uuid::Uuid;
6
7use crate::error::{GitAuthError, GitAuthResult};
8
9#[derive(Debug, Clone)]
10pub struct ResolvedRepo {
11 pub repo: repos::Model,
12 pub channel: channels::Model,
13 pub space: spaces::Model,
14}
15
16/// Resolve `/{space_name}/{repo_name}` to a concrete repository.
17///
18/// The space segment matches `spaces.lower_name`; the repository must live in
19/// one of that space's channels and match `repos.lower_name`. A name that maps
20/// to more than one repository (cross-channel collision) is rejected as
21/// ambiguous.
22pub async fn resolve_repo(
23 db: &impl ConnectionTrait,
24 space_name: &str,
25 repo_name: &str,
26) -> GitAuthResult<ResolvedRepo> {
27 let space = spaces::Entity::find()
28 .filter(spaces::Column::LowerName.eq(space_name.to_lowercase()))
29 .one(db)
30 .await?
31 .ok_or_else(|| GitAuthError::NotFound(format!("space '{space_name}' not found")))?;
32
33 let channels = channels::Entity::find()
34 .filter(channels::Column::SpaceId.eq(space.id))
35 .all(db)
36 .await?;
37 let channel_ids: Vec<Uuid> = channels.iter().map(|c| c.id).collect();
38
39 let repos = repos::Entity::find()
40 .filter(repos::Column::LowerName.eq(repo_name.to_lowercase()))
41 .filter(repos::Column::ChannelId.is_in(channel_ids))
42 .all(db)
43 .await?;
44
45 let repo = match repos.as_slice() {
46 [repo] => repo.clone(),
47 [] => {
48 return Err(GitAuthError::NotFound(format!(
49 "repository '{space_name}/{repo_name}' not found"
50 )));
51 }
52 _ => {
53 return Err(GitAuthError::Conflict(format!(
54 "repository path '{space_name}/{repo_name}' is ambiguous"
55 )));
56 }
57 };
58
59 let channel = channels::Entity::find_by_id(repo.channel_id)
60 .one(db)
61 .await?
62 .ok_or_else(|| {
63 GitAuthError::Internal(format!(
64 "channel {} of repository not found",
65 repo.channel_id
66 ))
67 })?;
68
69 Ok(ResolvedRepo {
70 repo,
71 channel,
72 space,
73 })
74}
Added lib/gitauth/service.rs View lib/gitauth/service.rs
@@ -0,0 +1,522 @@
1use std::sync::Arc;
2
3use cache::RedisCache;
4use db::conn::Db;
5use db::conn::sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
6use db::user::users;
7use jwt::{AccessClaims, HasUserExt, JwtState, RotationContext};
8use repo::actor::{ActorContext, RepoAccess};
9use uuid::Uuid;
10
11use crate::config::GitAuthConfig;
12use crate::error::{GitAuthError, GitAuthResult};
13use crate::permission;
14use crate::resolve::{self, ResolvedRepo};
15
16#[derive(Clone)]
17pub struct GitAuthService {
18 pub db: Db,
19 /// Optional Redis-backed cache, reserved for access-token blacklist /
20 /// rate-limit integration. `None` disables the cache (unit tests).
21 pub kv: Option<Arc<RedisCache>>,
22 pub jwt: JwtState,
23 pub config: GitAuthConfig,
24}
25
26impl GitAuthService {
27 pub fn new(db: Db, kv: Option<Arc<RedisCache>>, jwt: JwtState) -> Self {
28 Self {
29 db,
30 kv,
31 jwt,
32 config: GitAuthConfig::default(),
33 }
34 }
35
36 pub fn with_config(mut self, config: GitAuthConfig) -> Self {
37 self.config = config;
38 self
39 }
40
41 /// Resolve `/{space_name}/{repo_name}` to a repository without any
42 /// authentication check.
43 pub async fn resolve_repo(
44 &self,
45 space_name: &str,
46 repo_name: &str,
47 ) -> GitAuthResult<ResolvedRepo> {
48 let db = self.db.reader();
49 resolve::resolve_repo(&db, space_name, repo_name).await
50 }
51
52 /// Authenticate a git client presented as HTTP Basic credentials.
53 ///
54 /// The password is accepted either as a JWT access token (validated
55 /// against the access-token signing keys) or as the user's login password.
56 pub async fn authenticate(
57 &self,
58 username: &str,
59 password: &str,
60 ) -> GitAuthResult<AuthenticatedUser> {
61 let db = self.db.reader();
62 let Some(user) = users::Entity::find()
63 .filter(users::Column::LowerName.eq(username.to_lowercase()))
64 .one(&db)
65 .await?
66 else {
67 return Err(GitAuthError::Unauthorized("invalid credentials".into()));
68 };
69 if !user.is_active || user.prohibit_login {
70 return Err(GitAuthError::Unauthorized("account is not active".into()));
71 }
72
73 let user_id = self.verify_credentials(&user, password).await?;
74 let actor = ActorContext {
75 user_id: Some(user_id),
76 scopes: Default::default(),
77 is_site_admin: user.is_admin,
78 channel_role: None,
79 space_role: None,
80 };
81 Ok(AuthenticatedUser { user_id, actor })
82 }
83
84 /// Resolve the repository and enforce `required` access on behalf of
85 /// `actor`. Convenience wrapper used by protocol handlers.
86 pub async fn authorize(
87 &self,
88 space_name: &str,
89 repo_name: &str,
90 actor: &ActorContext,
91 required: RepoAccess,
92 ) -> GitAuthResult<ResolvedRepo> {
93 let db = self.db.reader();
94 let resolved = resolve::resolve_repo(&db, space_name, repo_name).await?;
95 permission::check_access(
96 &db,
97 actor,
98 &resolved,
99 required,
100 self.config.allow_anonymous_read,
101 )
102 .await?;
103 Ok(resolved)
104 }
105
106 async fn verify_credentials(&self, user: &users::Model, password: &str) -> GitAuthResult<Uuid> {
107 if let Ok(claims) = self.validate_jwt_access(password).await {
108 return Ok(claims.user_id());
109 }
110 let Some(hash) = user.passwd_hash.as_ref() else {
111 return Err(GitAuthError::Unauthorized("invalid credentials".into()));
112 };
113 let ok = auth::features::password_hash::verify_password(
114 auth::features::password_hash::PasswordInput { password },
115 &auth::features::password_hash::PasswordHashString(hash.clone()),
116 )?;
117 if ok {
118 Ok(user.uid)
119 } else {
120 Err(GitAuthError::Unauthorized("invalid credentials".into()))
121 }
122 }
123
124 async fn validate_jwt_access(&self, token: &str) -> GitAuthResult<AccessClaims> {
125 let cache = {
126 let mut cache = self.jwt.cache.write().await;
127 if cache.current_kid().is_none() {
128 cache.load_from_db(&self.jwt.db).await?;
129 }
130 cache
131 };
132 let ctx = RotationContext {
133 db: &self.jwt.db,
134 settings: &self.jwt.settings,
135 cache: &cache,
136 };
137 Ok(jwt::validate_access(&ctx, token).await?)
138 }
139}
140
141#[derive(Debug, Clone)]
142pub struct AuthenticatedUser {
143 pub user_id: Uuid,
144 pub actor: ActorContext,
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use db::channel::{channel_members, channels};
151 use db::conn::sea_orm::{ActiveModelTrait, ConnectionTrait, DatabaseConnection, Set};
152 use db::repo::{repo_collaborations, repos};
153 use db::space::spaces;
154 use jwt::{JwtSettings, SigningKeyCache};
155 use std::sync::Arc;
156 use time::OffsetDateTime;
157 use tokio::sync::RwLock;
158 use uuid::Uuid;
159
160 fn now() -> OffsetDateTime {
161 OffsetDateTime::now_utc()
162 }
163
164 async fn insert_user(
165 db: &impl ConnectionTrait,
166 uid: Uuid,
167 username: &str,
168 passwd_hash: Option<&str>,
169 ) {
170 let model = users::ActiveModel {
171 uid: Set(uid),
172 username: Set(username.to_string()),
173 lower_name: Set(username.to_lowercase()),
174 display_name: Set(username.to_string()),
175 avatar: Set(String::new()),
176 is_active: Set(true),
177 is_admin: Set(false),
178 is_restricted: Set(false),
179 prohibit_login: Set(false),
180 visibility: Set(users::ProfileVisibility::Public),
181 bot: Set(false),
182 system: Set(false),
183 totp_enabled: Set(false),
184 passwd_hash: Set(passwd_hash.map(|s| s.to_string())),
185 version: Set(1),
186 created_at: Set(now()),
187 updated_at: Set(now()),
188 ..Default::default()
189 };
190 model.insert(db).await.expect("insert user");
191 }
192
193 fn hash_for(password: &str) -> String {
194 use argon2::password_hash::rand_core::OsRng;
195 use argon2::password_hash::{PasswordHasher, SaltString};
196 let salt = SaltString::generate(&mut OsRng);
197 argon2::Argon2::default()
198 .hash_password(password.as_bytes(), &salt)
199 .expect("hash password")
200 .to_string()
201 }
202
203 fn actor_for_uid(uid: Uuid) -> ActorContext {
204 ActorContext {
205 user_id: Some(uid),
206 scopes: Default::default(),
207 is_site_admin: false,
208 channel_role: None,
209 space_role: None,
210 }
211 }
212
213 async fn insert_space(
214 db: &impl ConnectionTrait,
215 id: Uuid,
216 lower_name: &str,
217 owner_id: Uuid,
218 is_public: bool,
219 ) {
220 let model = spaces::ActiveModel {
221 id: Set(id),
222 lower_name: Set(lower_name.to_string()),
223 name: Set(lower_name.to_string()),
224 owner_id: Set(owner_id),
225 public: Set(is_public),
226 is_active: Set(true),
227 allow_join: Set(true),
228 allow_invite: Set(true),
229 member_count: Set(0),
230 allow_join_level: Set(spaces::AllowJoinLevel::None),
231 member_must_enable_2fa: Set(false),
232 created_at: Set(now()),
233 updated_at: Set(now()),
234 ..Default::default()
235 };
236 model.insert(db).await.expect("insert space");
237 }
238
239 async fn insert_channel(
240 db: &impl ConnectionTrait,
241 id: Uuid,
242 space_id: Uuid,
243 name: &str,
244 created_by: Uuid,
245 ) {
246 let model = channels::ActiveModel {
247 id: Set(id),
248 space_id: Set(Some(space_id)),
249 name: Set(name.to_string()),
250 position: Set(0),
251 is_archived: Set(false),
252 created_at: Set(now()),
253 updated_at: Set(now()),
254 created_by: Set(created_by),
255 ..Default::default()
256 };
257 model.insert(db).await.expect("insert channel");
258 }
259
260 async fn insert_repo(
261 db: &impl ConnectionTrait,
262 id: Uuid,
263 channel_id: Uuid,
264 name: &str,
265 is_private: bool,
266 ) {
267 let model = repos::ActiveModel {
268 id: Set(id),
269 channel_id: Set(channel_id),
270 lower_name: Set(name.to_lowercase()),
271 name: Set(name.to_string()),
272 default_branch: Set("main".to_string()),
273 nums_watch: Set(0),
274 nums_star: Set(0),
275 nums_pr: Set(0),
276 nums_commits: Set(0),
277 nums_branches: Set(0),
278 nums_tags: Set(0),
279 enable_lfs: Set(true),
280 is_private: Set(is_private),
281 is_archived: Set(false),
282 size: Set(0),
283 git_size: Set(0),
284 lfs_size: Set(0),
285 allow_rebase: Set(true),
286 allow_rebase_explicit: Set(true),
287 allow_squash_merge: Set(true),
288 allow_merge_commits: Set(true),
289 created_at: Set(now()),
290 object_format: Set("sha1".to_string()),
291 ..Default::default()
292 };
293 model.insert(db).await.expect("insert repo");
294 }
295
296 async fn insert_channel_member(
297 db: &impl ConnectionTrait,
298 channel_id: Uuid,
299 user_id: Uuid,
300 role: channel_members::ChannelMemberRole,
301 ) {
302 let model = channel_members::ActiveModel {
303 channel_id: Set(channel_id),
304 user_id: Set(user_id),
305 role: Set(role),
306 muted: Set(false),
307 joined_at: Set(now()),
308 ..Default::default()
309 };
310 model.insert(db).await.expect("insert channel member");
311 }
312
313 async fn insert_collaborator(
314 db: &impl ConnectionTrait,
315 repo_id: Uuid,
316 user_id: Uuid,
317 mode: repo_collaborations::CollaborationMode,
318 ) {
319 let model = repo_collaborations::ActiveModel {
320 id: Set(Uuid::new_v4()),
321 repo_id: Set(repo_id),
322 user_id: Set(user_id),
323 mode: Set(mode),
324 created_at: Set(now()),
325 updated_at: Set(now()),
326 };
327 model.insert(db).await.expect("insert collaborator");
328 }
329
330 async fn make_service() -> (GitAuthService, DatabaseConnection) {
331 let conn = crate::testing::setup_db().await;
332 let db = Db::from_connection(conn.clone());
333 let jwt = JwtState::new(
334 conn.clone(),
335 JwtSettings::default(),
336 Arc::new(RwLock::new(SigningKeyCache::new())),
337 );
338 let svc = GitAuthService::new(db, None, jwt);
339 (svc, conn)
340 }
341
342 #[tokio::test]
343 async fn authenticate_ok_with_password() {
344 let (svc, conn) = make_service().await;
345 let uid = Uuid::new_v4();
346 let hash = hash_for("secret");
347 insert_user(&conn, uid, "alice", Some(hash.as_ref())).await;
348 let auth = svc.authenticate("Alice", "secret").await.expect("auth");
349 assert_eq!(auth.user_id, uid);
350 assert!(auth.actor.is_authenticated());
351 assert!(!auth.actor.is_site_admin);
352 }
353
354 #[tokio::test]
355 async fn authenticate_rejects_wrong_password() {
356 let (svc, conn) = make_service().await;
357 let hash = hash_for("secret");
358 insert_user(&conn, Uuid::new_v4(), "alice", Some(hash.as_ref())).await;
359 let err = svc
360 .authenticate("alice", "wrong")
361 .await
362 .expect_err("should fail");
363 assert!(matches!(err, GitAuthError::Unauthorized(_)));
364 }
365
366 #[tokio::test]
367 async fn authenticate_rejects_unknown_user() {
368 let (svc, _conn) = make_service().await;
369 let err = svc
370 .authenticate("nobody", "x")
371 .await
372 .expect_err("should fail");
373 assert!(matches!(err, GitAuthError::Unauthorized(_)));
374 }
375
376 #[tokio::test]
377 async fn resolve_repo_ok_and_not_found() {
378 let (svc, conn) = make_service().await;
379 let owner = Uuid::new_v4();
380 let space_id = Uuid::new_v4();
381 let channel_id = Uuid::new_v4();
382 let repo_id = Uuid::new_v4();
383 insert_user(&conn, owner, "owner", None).await;
384 insert_space(&conn, space_id, "acme", owner, true).await;
385 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
386 insert_repo(&conn, repo_id, channel_id, "widget", false).await;
387
388 let resolved = svc.resolve_repo("acme", "widget").await.expect("resolve");
389 assert_eq!(resolved.repo.id, repo_id);
390 assert_eq!(resolved.channel.id, channel_id);
391 assert_eq!(resolved.space.id, space_id);
392
393 let err = svc
394 .resolve_repo("acme", "missing")
395 .await
396 .expect_err("missing");
397 assert!(matches!(err, GitAuthError::NotFound(_)));
398 let err = svc
399 .resolve_repo("nosuch", "widget")
400 .await
401 .expect_err("no space");
402 assert!(matches!(err, GitAuthError::NotFound(_)));
403 }
404
405 #[tokio::test]
406 async fn authorize_public_repo_anonymous_read() {
407 let (svc, conn) = make_service().await;
408 let owner = Uuid::new_v4();
409 let space_id = Uuid::new_v4();
410 let channel_id = Uuid::new_v4();
411 let repo_id = Uuid::new_v4();
412 insert_user(&conn, owner, "owner", None).await;
413 insert_space(&conn, space_id, "acme", owner, true).await;
414 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
415 insert_repo(&conn, repo_id, channel_id, "public", false).await;
416
417 let actor = ActorContext::anonymous();
418 svc.authorize("acme", "public", &actor, RepoAccess::Read)
419 .await
420 .expect("anonymous read public");
421 }
422
423 #[tokio::test]
424 async fn authorize_private_repo_anonymous_rejected() {
425 let (svc, conn) = make_service().await;
426 let owner = Uuid::new_v4();
427 let space_id = Uuid::new_v4();
428 let channel_id = Uuid::new_v4();
429 insert_user(&conn, owner, "owner", None).await;
430 insert_space(&conn, space_id, "acme", owner, true).await;
431 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
432 insert_repo(&conn, Uuid::new_v4(), channel_id, "secret", true).await;
433
434 let actor = ActorContext::anonymous();
435 let err = svc
436 .authorize("acme", "secret", &actor, RepoAccess::Read)
437 .await
438 .expect_err("denied");
439 assert!(matches!(err, GitAuthError::Unauthorized(_)));
440 }
441
442 #[tokio::test]
443 async fn authorize_collaborator_write() {
444 let (svc, conn) = make_service().await;
445 let owner = Uuid::new_v4();
446 let collab = Uuid::new_v4();
447 let space_id = Uuid::new_v4();
448 let channel_id = Uuid::new_v4();
449 let repo_id = Uuid::new_v4();
450 insert_user(&conn, owner, "owner", None).await;
451 insert_user(&conn, collab, "bob", None).await;
452 insert_space(&conn, space_id, "acme", owner, true).await;
453 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
454 insert_repo(&conn, repo_id, channel_id, "widget", true).await;
455 insert_collaborator(
456 &conn,
457 repo_id,
458 collab,
459 repo_collaborations::CollaborationMode::Write,
460 )
461 .await;
462
463 let actor = actor_for_uid(collab);
464 svc.authorize("acme", "widget", &actor, RepoAccess::Write)
465 .await
466 .expect("collab write");
467 let err = svc
468 .authorize("acme", "widget", &actor, RepoAccess::Admin)
469 .await
470 .expect_err("no admin");
471 assert!(matches!(err, GitAuthError::Forbidden(_)));
472 }
473
474 #[tokio::test]
475 async fn authorize_space_owner_admin() {
476 let (svc, conn) = make_service().await;
477 let owner = Uuid::new_v4();
478 let space_id = Uuid::new_v4();
479 let channel_id = Uuid::new_v4();
480 insert_user(&conn, owner, "owner", None).await;
481 insert_space(&conn, space_id, "acme", owner, true).await;
482 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
483 insert_repo(&conn, Uuid::new_v4(), channel_id, "widget", true).await;
484
485 let actor = actor_for_uid(owner);
486 svc.authorize("acme", "widget", &actor, RepoAccess::Admin)
487 .await
488 .expect("space owner admin");
489 }
490
491 #[tokio::test]
492 async fn channel_role_mapping() {
493 let (svc, conn) = make_service().await;
494 let owner = Uuid::new_v4();
495 let member = Uuid::new_v4();
496 let space_id = Uuid::new_v4();
497 let channel_id = Uuid::new_v4();
498 let repo_id = Uuid::new_v4();
499 insert_user(&conn, owner, "owner", None).await;
500 insert_user(&conn, member, "carol", None).await;
501 insert_space(&conn, space_id, "acme", owner, true).await;
502 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
503 insert_repo(&conn, repo_id, channel_id, "widget", false).await;
504 insert_channel_member(
505 &conn,
506 channel_id,
507 member,
508 channel_members::ChannelMemberRole::Admin,
509 )
510 .await;
511
512 let actor = actor_for_uid(member);
513 svc.authorize("acme", "widget", &actor, RepoAccess::Write)
514 .await
515 .expect("channel admin -> write");
516 let err = svc
517 .authorize("acme", "widget", &actor, RepoAccess::Admin)
518 .await
519 .expect_err("channel admin is not repo admin");
520 assert!(matches!(err, GitAuthError::Forbidden(_)));
521 }
522}
Added lib/gitauth/testing.rs View lib/gitauth/testing.rs
@@ -0,0 +1,26 @@
1use db::conn::sea_orm::{
2 ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, EntityTrait, Schema,
3};
4
5async fn create_table<E: EntityTrait>(db: &DatabaseConnection) {
6 let stmt = Schema::new(DatabaseBackend::Sqlite).create_table_from_entity(E::default());
7 db.execute(&stmt).await.expect("create table");
8}
9
10/// Build an in-memory sqlite database with the tables needed by gitauth.
11pub async fn setup_db() -> DatabaseConnection {
12 let db = Database::connect("sqlite::memory:")
13 .await
14 .expect("connect sqlite memory");
15 create_table::<db::user::users::Entity>(&db).await;
16 create_table::<db::space::spaces::Entity>(&db).await;
17 create_table::<db::channel::channels::Entity>(&db).await;
18 create_table::<db::channel::channel_members::Entity>(&db).await;
19 create_table::<db::space::space_members::Entity>(&db).await;
20 create_table::<db::space::space_roles::Entity>(&db).await;
21 create_table::<db::space::space_member_roles::Entity>(&db).await;
22 create_table::<db::space::space_role_permissions::Entity>(&db).await;
23 create_table::<db::repo::repo_collaborations::Entity>(&db).await;
24 create_table::<db::repo::repos::Entity>(&db).await;
25 db
26}