first pass at access control setup

This commit is contained in:
2026-03-24 14:45:07 -05:00
parent af5175b96a
commit 2ebb03b868
105 changed files with 6163 additions and 1416 deletions

View File

@@ -51,7 +51,7 @@ pub struct CreateActionInput {
pub pack: Id,
pub pack_ref: String,
pub label: String,
pub description: String,
pub description: Option<String>,
pub entrypoint: String,
pub runtime: Option<Id>,
pub runtime_version_constraint: Option<String>,
@@ -64,7 +64,7 @@ pub struct CreateActionInput {
#[derive(Debug, Clone, Default)]
pub struct UpdateActionInput {
pub label: Option<String>,
pub description: Option<String>,
pub description: Option<Patch<String>>,
pub entrypoint: Option<String>,
pub runtime: Option<Id>,
pub runtime_version_constraint: Option<Patch<String>>,
@@ -210,7 +210,10 @@ impl Update for ActionRepository {
query.push(", ");
}
query.push("description = ");
query.push_bind(description);
match description {
Patch::Set(value) => query.push_bind(value),
Patch::Clear => query.push_bind(Option::<String>::None),
};
has_updates = true;
}

View File

@@ -577,6 +577,14 @@ pub struct CreateArtifactVersionInput {
}
impl ArtifactVersionRepository {
fn select_columns_with_alias(alias: &str) -> String {
format!(
"{alias}.id, {alias}.artifact, {alias}.version, {alias}.content_type, \
{alias}.size_bytes, NULL::bytea AS content, {alias}.content_json, \
{alias}.file_path, {alias}.meta, {alias}.created_by, {alias}.created"
)
}
/// Find a version by ID (without binary content for performance)
pub async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<ArtifactVersion>>
where
@@ -812,14 +820,11 @@ impl ArtifactVersionRepository {
E: Executor<'e, Database = Postgres> + 'e,
{
let query = format!(
"SELECT av.{} \
"SELECT {} \
FROM artifact_version av \
JOIN artifact a ON av.artifact = a.id \
WHERE a.execution = $1 AND av.file_path IS NOT NULL",
artifact_version::SELECT_COLUMNS
.split(", ")
.collect::<Vec<_>>()
.join(", av.")
Self::select_columns_with_alias("av")
);
sqlx::query_as::<_, ArtifactVersion>(&query)
.bind(execution_id)
@@ -847,3 +852,18 @@ impl ArtifactVersionRepository {
.map_err(Into::into)
}
}
#[cfg(test)]
mod tests {
use super::ArtifactVersionRepository;
#[test]
fn aliased_select_columns_keep_null_content_expression_unqualified() {
let columns = ArtifactVersionRepository::select_columns_with_alias("av");
assert!(columns.contains("av.id"));
assert!(columns.contains("av.file_path"));
assert!(columns.contains("NULL::bytea AS content"));
assert!(!columns.contains("av.NULL::bytea AS content"));
}
}

View File

@@ -28,6 +28,7 @@ pub struct UpdateIdentityInput {
pub display_name: Option<String>,
pub password_hash: Option<String>,
pub attributes: Option<JsonDict>,
pub frozen: Option<bool>,
}
#[async_trait::async_trait]
@@ -37,7 +38,7 @@ impl FindById for IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE id = $1"
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated FROM identity WHERE id = $1"
).bind(id).fetch_optional(executor).await.map_err(Into::into)
}
}
@@ -49,7 +50,7 @@ impl List for IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity ORDER BY login ASC"
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated FROM identity ORDER BY login ASC"
).fetch_all(executor).await.map_err(Into::into)
}
}
@@ -62,7 +63,7 @@ impl Create for IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Identity>(
"INSERT INTO identity (login, display_name, password_hash, attributes) VALUES ($1, $2, $3, $4) RETURNING id, login, display_name, password_hash, attributes, created, updated"
"INSERT INTO identity (login, display_name, password_hash, attributes) VALUES ($1, $2, $3, $4) RETURNING id, login, display_name, password_hash, attributes, frozen, created, updated"
)
.bind(&input.login)
.bind(&input.display_name)
@@ -111,6 +112,13 @@ impl Update for IdentityRepository {
query.push("attributes = ").push_bind(attributes);
has_updates = true;
}
if let Some(frozen) = input.frozen {
if has_updates {
query.push(", ");
}
query.push("frozen = ").push_bind(frozen);
has_updates = true;
}
if !has_updates {
// No updates requested, fetch and return existing entity
@@ -119,7 +127,7 @@ impl Update for IdentityRepository {
query.push(", updated = NOW() WHERE id = ").push_bind(id);
query.push(
" RETURNING id, login, display_name, password_hash, attributes, created, updated",
" RETURNING id, login, display_name, password_hash, attributes, frozen, created, updated",
);
query
@@ -156,7 +164,7 @@ impl IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated FROM identity WHERE login = $1"
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated FROM identity WHERE login = $1"
).bind(login).fetch_optional(executor).await.map_err(Into::into)
}
@@ -169,7 +177,7 @@ impl IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated
FROM identity
WHERE attributes->'oidc'->>'issuer' = $1
AND attributes->'oidc'->>'sub' = $2",
@@ -190,7 +198,7 @@ impl IdentityRepository {
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, Identity>(
"SELECT id, login, display_name, password_hash, attributes, created, updated
"SELECT id, login, display_name, password_hash, attributes, frozen, created, updated
FROM identity
WHERE attributes->'ldap'->>'server_url' = $1
AND attributes->'ldap'->>'dn' = $2",
@@ -363,6 +371,27 @@ impl PermissionSetRepository {
.map_err(Into::into)
}
pub async fn find_by_roles<'e, E>(executor: E, roles: &[String]) -> Result<Vec<PermissionSet>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
if roles.is_empty() {
return Ok(Vec::new());
}
sqlx::query_as::<_, PermissionSet>(
"SELECT DISTINCT ps.id, ps.ref, ps.pack, ps.pack_ref, ps.label, ps.description, ps.grants, ps.created, ps.updated
FROM permission_set ps
INNER JOIN permission_set_role_assignment psra ON psra.permset = ps.id
WHERE psra.role = ANY($1)
ORDER BY ps.ref ASC",
)
.bind(roles)
.fetch_all(executor)
.await
.map_err(Into::into)
}
/// Delete permission sets belonging to a pack whose refs are NOT in the given set.
///
/// Used during pack reinstallation to clean up permission sets that were
@@ -481,3 +510,231 @@ impl PermissionAssignmentRepository {
.map_err(Into::into)
}
}
pub struct IdentityRoleAssignmentRepository;
impl Repository for IdentityRoleAssignmentRepository {
type Entity = IdentityRoleAssignment;
fn table_name() -> &'static str {
"identity_role_assignment"
}
}
#[derive(Debug, Clone)]
pub struct CreateIdentityRoleAssignmentInput {
pub identity: Id,
pub role: String,
pub source: String,
pub managed: bool,
}
#[async_trait::async_trait]
impl FindById for IdentityRoleAssignmentRepository {
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, IdentityRoleAssignment>(
"SELECT id, identity, role, source, managed, created, updated FROM identity_role_assignment WHERE id = $1"
)
.bind(id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Create for IdentityRoleAssignmentRepository {
type CreateInput = CreateIdentityRoleAssignmentInput;
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, IdentityRoleAssignment>(
"INSERT INTO identity_role_assignment (identity, role, source, managed)
VALUES ($1, $2, $3, $4)
RETURNING id, identity, role, source, managed, created, updated",
)
.bind(input.identity)
.bind(&input.role)
.bind(&input.source)
.bind(input.managed)
.fetch_one(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Delete for IdentityRoleAssignmentRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM identity_role_assignment WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl IdentityRoleAssignmentRepository {
pub async fn find_by_identity<'e, E>(
executor: E,
identity_id: Id,
) -> Result<Vec<IdentityRoleAssignment>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, IdentityRoleAssignment>(
"SELECT id, identity, role, source, managed, created, updated
FROM identity_role_assignment
WHERE identity = $1
ORDER BY role ASC",
)
.bind(identity_id)
.fetch_all(executor)
.await
.map_err(Into::into)
}
pub async fn find_role_names_by_identity<'e, E>(
executor: E,
identity_id: Id,
) -> Result<Vec<String>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_scalar::<_, String>(
"SELECT role FROM identity_role_assignment WHERE identity = $1 ORDER BY role ASC",
)
.bind(identity_id)
.fetch_all(executor)
.await
.map_err(Into::into)
}
pub async fn replace_managed_roles<'e, E>(
executor: E,
identity_id: Id,
source: &str,
roles: &[String],
) -> Result<()>
where
E: Executor<'e, Database = Postgres> + Copy + 'e,
{
sqlx::query(
"DELETE FROM identity_role_assignment WHERE identity = $1 AND source = $2 AND managed = true",
)
.bind(identity_id)
.bind(source)
.execute(executor)
.await?;
for role in roles {
sqlx::query(
"INSERT INTO identity_role_assignment (identity, role, source, managed)
VALUES ($1, $2, $3, true)
ON CONFLICT (identity, role) DO UPDATE
SET source = EXCLUDED.source,
managed = EXCLUDED.managed,
updated = NOW()",
)
.bind(identity_id)
.bind(role)
.bind(source)
.execute(executor)
.await?;
}
Ok(())
}
}
pub struct PermissionSetRoleAssignmentRepository;
impl Repository for PermissionSetRoleAssignmentRepository {
type Entity = PermissionSetRoleAssignment;
fn table_name() -> &'static str {
"permission_set_role_assignment"
}
}
#[derive(Debug, Clone)]
pub struct CreatePermissionSetRoleAssignmentInput {
pub permset: Id,
pub role: String,
}
#[async_trait::async_trait]
impl FindById for PermissionSetRoleAssignmentRepository {
async fn find_by_id<'e, E>(executor: E, id: i64) -> Result<Option<Self::Entity>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, PermissionSetRoleAssignment>(
"SELECT id, permset, role, created FROM permission_set_role_assignment WHERE id = $1",
)
.bind(id)
.fetch_optional(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Create for PermissionSetRoleAssignmentRepository {
type CreateInput = CreatePermissionSetRoleAssignmentInput;
async fn create<'e, E>(executor: E, input: Self::CreateInput) -> Result<Self::Entity>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, PermissionSetRoleAssignment>(
"INSERT INTO permission_set_role_assignment (permset, role)
VALUES ($1, $2)
RETURNING id, permset, role, created",
)
.bind(input.permset)
.bind(&input.role)
.fetch_one(executor)
.await
.map_err(Into::into)
}
}
#[async_trait::async_trait]
impl Delete for PermissionSetRoleAssignmentRepository {
async fn delete<'e, E>(executor: E, id: i64) -> Result<bool>
where
E: Executor<'e, Database = Postgres> + 'e,
{
let result = sqlx::query("DELETE FROM permission_set_role_assignment WHERE id = $1")
.bind(id)
.execute(executor)
.await?;
Ok(result.rows_affected() > 0)
}
}
impl PermissionSetRoleAssignmentRepository {
pub async fn find_by_permission_set<'e, E>(
executor: E,
permset_id: Id,
) -> Result<Vec<PermissionSetRoleAssignment>>
where
E: Executor<'e, Database = Postgres> + 'e,
{
sqlx::query_as::<_, PermissionSetRoleAssignment>(
"SELECT id, permset, role, created
FROM permission_set_role_assignment
WHERE permset = $1
ORDER BY role ASC",
)
.bind(permset_id)
.fetch_all(executor)
.await
.map_err(Into::into)
}
}

View File

@@ -6,7 +6,7 @@ use crate::models::{rule::*, Id};
use crate::{Error, Result};
use sqlx::{Executor, Postgres, QueryBuilder};
use super::{Create, Delete, FindById, FindByRef, List, Repository, Update};
use super::{Create, Delete, FindById, FindByRef, List, Patch, Repository, Update};
/// Filters for [`RuleRepository::list_search`].
///
@@ -41,7 +41,7 @@ pub struct RestoreRuleInput {
pub pack: Id,
pub pack_ref: String,
pub label: String,
pub description: String,
pub description: Option<String>,
pub action: Option<Id>,
pub action_ref: String,
pub trigger: Option<Id>,
@@ -70,7 +70,7 @@ pub struct CreateRuleInput {
pub pack: Id,
pub pack_ref: String,
pub label: String,
pub description: String,
pub description: Option<String>,
pub action: Id,
pub action_ref: String,
pub trigger: Id,
@@ -86,7 +86,7 @@ pub struct CreateRuleInput {
#[derive(Debug, Clone, Default)]
pub struct UpdateRuleInput {
pub label: Option<String>,
pub description: Option<String>,
pub description: Option<Patch<String>>,
pub conditions: Option<serde_json::Value>,
pub action_params: Option<serde_json::Value>,
pub trigger_params: Option<serde_json::Value>,
@@ -228,7 +228,10 @@ impl Update for RuleRepository {
query.push(", ");
}
query.push("description = ");
query.push_bind(description);
match description {
Patch::Set(value) => query.push_bind(value),
Patch::Clear => query.push_bind(Option::<String>::None),
};
has_updates = true;
}

View File

@@ -665,7 +665,7 @@ pub struct CreateSensorInput {
pub pack: Option<Id>,
pub pack_ref: Option<String>,
pub label: String,
pub description: String,
pub description: Option<String>,
pub entrypoint: String,
pub runtime: Id,
pub runtime_ref: String,
@@ -681,7 +681,7 @@ pub struct CreateSensorInput {
#[derive(Debug, Clone, Default)]
pub struct UpdateSensorInput {
pub label: Option<String>,
pub description: Option<String>,
pub description: Option<Patch<String>>,
pub entrypoint: Option<String>,
pub runtime: Option<Id>,
pub runtime_ref: Option<String>,
@@ -830,7 +830,10 @@ impl Update for SensorRepository {
query.push(", ");
}
query.push("description = ");
query.push_bind(description);
match description {
Patch::Set(value) => query.push_bind(value),
Patch::Clear => query.push_bind(Option::<String>::None),
};
has_updates = true;
}