fix: Authorize user Git pushes #35

Manually merged
day01 merged 1 commits from fix/0.6-user-git-push into develop 2026-08-30 17:13:35 +00:00
5 changed files with 128 additions and 35 deletions
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,228 +1,229 @@
use std::collections::BTreeSet;

use chrono::{DateTime, Utc};
use syncode_identity_model::{Capability, Resource};
use uuid::Uuid;

use crate::sessions::hash_token;
use crate::{Postgres, StoreError, decode_capabilities};

#[derive(Clone, Debug)]
pub struct AccessTokenRecord {
pub id: Uuid,
pub owner_user_id: Uuid,
pub local_agent_id: Option<Uuid>,
pub capabilities: BTreeSet<Capability>,
pub resource: Option<Resource>,
pub audience: String,
pub expires_at: DateTime<Utc>,
}

struct AccessTokenRow {
id: Uuid,
owner_user_id: Uuid,
local_agent_id: Option<Uuid>,
capabilities: Vec<String>,
resource_kind: Option<String>,
resource_id: Option<Uuid>,
audience: String,
expires_at: DateTime<Utc>,
}

impl Postgres {
pub async fn issue_user_repository_token(
&self,
user_id: Uuid,
repository_id: Uuid,
token: &str,
expires_at: DateTime<Utc>,
) -> Result<Uuid, StoreError> {
let id = Uuid::new_v4();
sqlx::query_scalar!(
"INSERT INTO access_token (
id, user_id, local_agent_id, name, token_hash, capabilities,
resource_kind, resource_id, audience, expires_at, created_at
)
SELECT $1, account.id, NULL, 'syn-repository-clone', $4,
ARRAY['repo:read'], 'repository', $3, 'git_http', $5, now()
FROM \"user\" account
WHERE account.id = $2 AND account.deleted_at IS NULL
RETURNING access_token.id",
id,
user_id,
repository_id,
hash_token(token),
expires_at
)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}

pub async fn issue_user_repository_api_token(
&self,
user_id: Uuid,
repository_id: Uuid,
capabilities: &BTreeSet<Capability>,
token: &str,
expires_at: DateTime<Utc>,
) -> Result<Uuid, StoreError> {
let id = Uuid::new_v4();
let capability_strings = capabilities
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
sqlx::query_scalar!(
"INSERT INTO access_token (
id, user_id, local_agent_id, name, token_hash, capabilities,
resource_kind, resource_id, audience, expires_at, created_at
)
SELECT $1, account.id, NULL, 'syn-repository-api', $5, $3,
'repository', $4, 'api', $6, now()
FROM \"user\" account
WHERE account.id = $2 AND account.deleted_at IS NULL
RETURNING access_token.id",
id,
user_id,
&capability_strings,
repository_id,
hash_token(token),
expires_at
)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}

pub async fn issue_local_agent_repository_api_token(
&self,
local_agent_id: Uuid,
repository_id: Uuid,
capabilities: &BTreeSet<Capability>,
token: &str,
expires_at: DateTime<Utc>,
) -> Result<Uuid, StoreError> {
let id = Uuid::new_v4();
let capability_strings = capabilities
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
sqlx::query_scalar!(
"INSERT INTO access_token (
id, user_id, local_agent_id, name, token_hash, capabilities,
resource_kind, resource_id, audience, expires_at, created_at
)
SELECT $1, NULL, agent.id, 'syn-local-agent-repository-api', $5, $3,
'repository', $4, 'api', $6, now()
FROM local_agent agent
WHERE agent.id = $2 AND agent.status = 'active' AND agent.revoked_at IS NULL
RETURNING access_token.id",
id,
local_agent_id,
&capability_strings,
repository_id,
hash_token(token),
expires_at
)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}

pub async fn issue_local_agent_repository_token(
&self,
local_agent_id: Uuid,
repository_id: Uuid,
token: &str,
expires_at: DateTime<Utc>,
) -> Result<Uuid, StoreError> {
let id = Uuid::new_v4();
sqlx::query_scalar!(
"INSERT INTO access_token (
id, user_id, local_agent_id, name, token_hash, capabilities,
resource_kind, resource_id, audience, expires_at, created_at
)
SELECT $1, NULL, agent.id, 'local-agent-repository', $4,
agent.restriction, 'repository', $3, 'git_http', $5, now()
FROM local_agent agent
WHERE agent.id = $2 AND agent.status = 'active' AND agent.revoked_at IS NULL
RETURNING access_token.id",
id,
local_agent_id,
repository_id,
hash_token(token),
expires_at
)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}

pub async fn load_access_token(
&self,
token_id: Uuid,
) -> Result<Option<AccessTokenRecord>, StoreError> {
let row = sqlx::query_as!(
AccessTokenRow,
r#"SELECT token.id AS "id!", COALESCE(token.user_id, agent.owner_user_id) AS "owner_user_id!",
token.local_agent_id AS "local_agent_id?", token.capabilities AS "capabilities!",
token.resource_kind::text AS "resource_kind?", token.resource_id AS "resource_id?",
token.audience AS "audience!", token.expires_at AS "expires_at!"
FROM access_token token
LEFT JOIN local_agent agent ON agent.id = token.local_agent_id
WHERE token.id = $1 AND token.revoked_at IS NULL
AND token.expires_at > now()
AND (token.local_agent_id IS NULL OR (
agent.status = 'active' AND agent.revoked_at IS NULL
))"#,
token_id
)
.fetch_optional(&self.pool)
.await?;
row.map(access_token_record).transpose()
}

pub async fn validate_access_token(
&self,
token: &str,
) -> Result<Option<AccessTokenRecord>, StoreError> {
let row = sqlx::query_as!(
AccessTokenRow,
r#"SELECT token.id AS "id!", COALESCE(token.user_id, agent.owner_user_id) AS "owner_user_id!",
token.local_agent_id AS "local_agent_id?", token.capabilities AS "capabilities!",
token.resource_kind::text AS "resource_kind?", token.resource_id AS "resource_id?",
token.audience AS "audience!", token.expires_at AS "expires_at!"
FROM access_token token
LEFT JOIN local_agent agent ON agent.id = token.local_agent_id
WHERE token.token_hash = $1 AND token.revoked_at IS NULL
AND token.expires_at > now()
AND (token.local_agent_id IS NULL OR (
agent.status = 'active' AND agent.revoked_at IS NULL
))"#,
hash_token(token)
)
.fetch_optional(&self.pool)
.await?;
row.map(access_token_record).transpose()
}
}

fn access_token_record(row: AccessTokenRow) -> Result<AccessTokenRecord, StoreError> {
let resource = match (row.resource_kind.as_deref(), row.resource_id) {
(None, None) => None,
(Some("repository"), Some(id)) => Some(Resource::repository(id.into())),
(Some("organization"), Some(id)) => Some(Resource::organization(id.into())),
(Some("instance"), None) => Some(Resource::instance()),
(Some(kind), _) => return Err(StoreError::UnknownKind(kind.to_owned())),
(None, Some(_)) => return Err(StoreError::UnknownKind("missing resource kind".to_owned())),
};
Ok(AccessTokenRecord {
id: row.id,
owner_user_id: row.owner_user_id,
local_agent_id: row.local_agent_id,
capabilities: decode_capabilities(row.capabilities)?,
resource,
audience: row.audience,
expires_at: row.expires_at,
})
}
use std::collections::BTreeSet;

use chrono::{DateTime, Utc};
use syncode_identity_model::{Capability, Resource};
use uuid::Uuid;

use crate::sessions::hash_token;
use crate::{Postgres, StoreError, decode_capabilities};

#[derive(Clone, Debug)]
pub struct AccessTokenRecord {
pub id: Uuid,
pub owner_user_id: Uuid,
pub local_agent_id: Option<Uuid>,
pub capabilities: BTreeSet<Capability>,
pub resource: Option<Resource>,
pub audience: String,
pub expires_at: DateTime<Utc>,
}

struct AccessTokenRow {
id: Uuid,
owner_user_id: Uuid,
local_agent_id: Option<Uuid>,
capabilities: Vec<String>,
resource_kind: Option<String>,
resource_id: Option<Uuid>,
audience: String,
expires_at: DateTime<Utc>,
}

impl Postgres {
pub async fn issue_user_repository_token(
&self,
user_id: Uuid,
repository_id: Uuid,
token: &str,
expires_at: DateTime<Utc>,
) -> Result<Uuid, StoreError> {
let id = Uuid::new_v4();
sqlx::query_scalar!(
"INSERT INTO access_token (
id, user_id, local_agent_id, name, token_hash, capabilities,
resource_kind, resource_id, audience, expires_at, created_at
)
SELECT $1, account.id, NULL, 'syn-repository-clone', $4,
ARRAY['repo:read', 'code:push', 'code:force-push'],
'repository', $3, 'git_http', $5, now()
FROM \"user\" account
WHERE account.id = $2 AND account.deleted_at IS NULL
RETURNING access_token.id",
id,
user_id,
repository_id,
hash_token(token),
expires_at
)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}

pub async fn issue_user_repository_api_token(
&self,
user_id: Uuid,
repository_id: Uuid,
capabilities: &BTreeSet<Capability>,
token: &str,
expires_at: DateTime<Utc>,
) -> Result<Uuid, StoreError> {
let id = Uuid::new_v4();
let capability_strings = capabilities
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
sqlx::query_scalar!(
"INSERT INTO access_token (
id, user_id, local_agent_id, name, token_hash, capabilities,
resource_kind, resource_id, audience, expires_at, created_at
)
SELECT $1, account.id, NULL, 'syn-repository-api', $5, $3,
'repository', $4, 'api', $6, now()
FROM \"user\" account
WHERE account.id = $2 AND account.deleted_at IS NULL
RETURNING access_token.id",
id,
user_id,
&capability_strings,
repository_id,
hash_token(token),
expires_at
)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}

pub async fn issue_local_agent_repository_api_token(
&self,
local_agent_id: Uuid,
repository_id: Uuid,
capabilities: &BTreeSet<Capability>,
token: &str,
expires_at: DateTime<Utc>,
) -> Result<Uuid, StoreError> {
let id = Uuid::new_v4();
let capability_strings = capabilities
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
sqlx::query_scalar!(
"INSERT INTO access_token (
id, user_id, local_agent_id, name, token_hash, capabilities,
resource_kind, resource_id, audience, expires_at, created_at
)
SELECT $1, NULL, agent.id, 'syn-local-agent-repository-api', $5, $3,
'repository', $4, 'api', $6, now()
FROM local_agent agent
WHERE agent.id = $2 AND agent.status = 'active' AND agent.revoked_at IS NULL
RETURNING access_token.id",
id,
local_agent_id,
&capability_strings,
repository_id,
hash_token(token),
expires_at
)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}

pub async fn issue_local_agent_repository_token(
&self,
local_agent_id: Uuid,
repository_id: Uuid,
token: &str,
expires_at: DateTime<Utc>,
) -> Result<Uuid, StoreError> {
let id = Uuid::new_v4();
sqlx::query_scalar!(
"INSERT INTO access_token (
id, user_id, local_agent_id, name, token_hash, capabilities,
resource_kind, resource_id, audience, expires_at, created_at
)
SELECT $1, NULL, agent.id, 'local-agent-repository', $4,
agent.restriction, 'repository', $3, 'git_http', $5, now()
FROM local_agent agent
WHERE agent.id = $2 AND agent.status = 'active' AND agent.revoked_at IS NULL
RETURNING access_token.id",
id,
local_agent_id,
repository_id,
hash_token(token),
expires_at
)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}

pub async fn load_access_token(
&self,
token_id: Uuid,
) -> Result<Option<AccessTokenRecord>, StoreError> {
let row = sqlx::query_as!(
AccessTokenRow,
r#"SELECT token.id AS "id!", COALESCE(token.user_id, agent.owner_user_id) AS "owner_user_id!",
token.local_agent_id AS "local_agent_id?", token.capabilities AS "capabilities!",
token.resource_kind::text AS "resource_kind?", token.resource_id AS "resource_id?",
token.audience AS "audience!", token.expires_at AS "expires_at!"
FROM access_token token
LEFT JOIN local_agent agent ON agent.id = token.local_agent_id
WHERE token.id = $1 AND token.revoked_at IS NULL
AND token.expires_at > now()
AND (token.local_agent_id IS NULL OR (
agent.status = 'active' AND agent.revoked_at IS NULL
))"#,
token_id
)
.fetch_optional(&self.pool)
.await?;
row.map(access_token_record).transpose()
}

pub async fn validate_access_token(
&self,
token: &str,
) -> Result<Option<AccessTokenRecord>, StoreError> {
let row = sqlx::query_as!(
AccessTokenRow,
r#"SELECT token.id AS "id!", COALESCE(token.user_id, agent.owner_user_id) AS "owner_user_id!",
token.local_agent_id AS "local_agent_id?", token.capabilities AS "capabilities!",
token.resource_kind::text AS "resource_kind?", token.resource_id AS "resource_id?",
token.audience AS "audience!", token.expires_at AS "expires_at!"
FROM access_token token
LEFT JOIN local_agent agent ON agent.id = token.local_agent_id
WHERE token.token_hash = $1 AND token.revoked_at IS NULL
AND token.expires_at > now()
AND (token.local_agent_id IS NULL OR (
agent.status = 'active' AND agent.revoked_at IS NULL
))"#,
hash_token(token)
)
.fetch_optional(&self.pool)
.await?;
row.map(access_token_record).transpose()
}
}

fn access_token_record(row: AccessTokenRow) -> Result<AccessTokenRecord, StoreError> {
let resource = match (row.resource_kind.as_deref(), row.resource_id) {
(None, None) => None,
(Some("repository"), Some(id)) => Some(Resource::repository(id.into())),
(Some("organization"), Some(id)) => Some(Resource::organization(id.into())),
(Some("instance"), None) => Some(Resource::instance()),
(Some(kind), _) => return Err(StoreError::UnknownKind(kind.to_owned())),
(None, Some(_)) => return Err(StoreError::UnknownKind("missing resource kind".to_owned())),
};
Ok(AccessTokenRecord {
id: row.id,
owner_user_id: row.owner_user_id,
local_agent_id: row.local_agent_id,
capabilities: decode_capabilities(row.capabilities)?,
resource,
audience: row.audience,
expires_at: row.expires_at,
})
}
@@ -1,32 +1,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO access_token (\n id, user_id, local_agent_id, name, token_hash, capabilities,\n resource_kind, resource_id, audience, expires_at, created_at\n )\n SELECT $1, account.id, NULL, 'syn-repository-clone', $4,\n ARRAY['repo:read'], 'repository', $3, 'git_http', $5, now()\n FROM \"user\" account\n WHERE account.id = $2 AND account.deleted_at IS NULL\n RETURNING access_token.id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid",
"origin": {
"Table": {
"table": "access_token",
"name": "id"
}
}
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text",
"Timestamptz"
]
},
"nullable": [
false
]
},
"hash": "b1a10424732b7210e51747df4147ba94bfa7992ae17edb1eb1204cb1ed05b443"
}
@@ -1,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO access_token (\n id, user_id, local_agent_id, name, token_hash, capabilities,\n resource_kind, resource_id, audience, expires_at, created_at\n )\n SELECT $1, account.id, NULL, 'syn-repository-clone', $4,\n ARRAY['repo:read', 'code:push', 'code:force-push'],\n 'repository', $3, 'git_http', $5, now()\n FROM \"user\" account\n WHERE account.id = $2 AND account.deleted_at IS NULL\n RETURNING access_token.id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid",
"origin": {
"Table": {
"table": "access_token",
"name": "id"
}
}
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Uuid",
"Text",
"Timestamptz"
]
},
"nullable": [
false
]
},
"hash": "d93e26d00910a3370283ccb1d5ed2eb8ba57af632f2f789ae57e814e7c4edbec"
}
@@ -1,0 +1,43 @@
ALTER TABLE access_token
DROP CONSTRAINT access_token_git_http_scope_check;

UPDATE access_token
SET capabilities = ARRAY['repo:read', 'code:push', 'code:force-push']
WHERE audience = 'git_http'
AND user_id IS NOT NULL
AND local_agent_id IS NULL
AND name = 'syn-repository-clone';

ALTER TABLE access_token
ADD CONSTRAINT access_token_git_http_scope_check CHECK (
audience != 'git_http'
OR (
resource_kind = 'repository'
AND resource_id IS NOT NULL
AND (
(
local_agent_id IS NOT NULL
AND user_id IS NULL
AND name = 'local-agent-repository'
)
OR (
user_id IS NOT NULL
AND local_agent_id IS NULL
AND (
(
name = 'syn-repository-clone'
AND capabilities = ARRAY[
'repo:read',
'code:push',
'code:force-push'
]
)
OR (
name = 'syncode-workflow-repository'
AND capabilities = ARRAY['repo:read']
)
)
)
)
)
);