Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions client-sdks/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ pnpm run generate:manager-api # Regenerate manager TypeScript SDK
pnpm run generate:platform-api # Regenerate platform TypeScript SDK from checked-in spec
```

### Platform TypeScript SDK drift

Before including a platform TypeScript SDK regeneration in a feature PR,
inspect the generated diff from a clean worktree. If it rewrites endpoints or
models unrelated to the feature, keep the relevant OpenAPI and Rust inputs
produced by the supported generation pipeline and move the full TypeScript SDK
refresh to a dedicated PR. Do not manually edit or selectively patch generated
SDK code to force a smaller diff.

## Don't

- Don't edit generated code — regenerate from the OpenAPI spec
Expand Down
2 changes: 1 addition & 1 deletion client-sdks/platform/openapi.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client-sdks/platform/rust/openapi-3.0.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client-sdks/platform/rust/openapi.json

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions crates/alien-commands/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ pub enum ErrorData {
command_id: String,
},

/// The manager serving a hosted command capability no longer owns its
/// deployment. Clients should refresh bootstrap discovery and retry.
#[error(
code = "COMMAND_CONNECTION_STALE",
message = "Command connection is stale for '{resource}'",
retryable = "true",
internal = "false",
http_status_code = 401
)]
CommandConnectionStale {
/// Deployment or command identifier whose manager assignment changed
resource: String,
},

/// Explicitly requested command target does not exist in the deployment
/// (or is not command-capable). Also returned for an empty resource id.
///
Expand Down
3 changes: 3 additions & 0 deletions crates/alien-commands/src/server/command_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ pub struct CommandAccessContext {
pub workspace_id: String,
pub project_id: String,
pub deployment_id: String,
pub target: CommandTarget,
}

/// Internal command record stored in memory
Expand Down Expand Up @@ -274,6 +275,7 @@ pub trait CommandRegistry: Send + Sync {
workspace_id: status.workspace_id,
project_id: status.project_id,
deployment_id: status.deployment_id,
target: status.target,
}))
}

Expand Down Expand Up @@ -859,6 +861,7 @@ mod tests {
workspace_id: "default".to_string(),
project_id: "default".to_string(),
deployment_id: "dep-1".to_string(),
target: CommandTarget::new("worker-1", CommandTargetType::Worker),
}
);
}
Expand Down
1 change: 1 addition & 0 deletions crates/alien-manager/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ toml = { workspace = true }
futures = { workspace = true }

[dev-dependencies]
mockall = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tempfile = { workspace = true }
httpmock = { workspace = true }
Expand Down
21 changes: 21 additions & 0 deletions crates/alien-manager/src/auth/authz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::traits::{
release_store::ReleaseRecord,
};
use alien_commands::server::CommandAccessContext;
use alien_core::CommandTarget;

/// Context for create operations. Carries the parent identifiers + workspace
/// the create is targeting; the entity itself does not exist yet.
Expand Down Expand Up @@ -67,6 +68,26 @@ pub trait Authz: Send + Sync {
fn can_execute_command(&self, subject: &Subject, deployment: &DeploymentRecord) -> bool {
self.can_act_on_deployment(subject, deployment)
}
/// Authorize lease acquisition for an exact receiver target.
fn can_receive_command(
&self,
subject: &Subject,
deployment: &DeploymentRecord,
_target: &CommandTarget,
) -> bool {
self.can_execute_command(subject, deployment)
}
/// Authorize bearer response/release access from canonical command data.
/// Implementations opt commands-only receiver capabilities into this gate;
/// existing deployment/group/admin paths continue through
/// `can_execute_command`.
fn can_execute_command_context(
&self,
_subject: &Subject,
_command: &CommandAccessContext,
) -> bool {
false
}
fn can_read_command(&self, subject: &Subject, deployment: &DeploymentRecord) -> bool;
/// Authorize a read from the canonical command record without loading its
/// deployment. Deployment-group scope is intentionally handled through
Expand Down
154 changes: 154 additions & 0 deletions crates/alien-manager/src/auth/command_capability.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//! Shared authorization rules for short-lived commands-only capabilities.
//!
//! OSS and hosted managers have different policies for their normal subjects,
//! but a commands capability must mean exactly the same thing in both:
//! sender access is bound to one deployment, and receiver access is bound to
//! one deployment plus one exact target.

use alien_commands::server::CommandAccessContext;
use alien_core::CommandTarget;

use crate::auth::{CommandCapability, Role, Scope, Subject};
use crate::traits::deployment_store::DeploymentRecord;

/// Decide sender access from the signed deployment id before entity lookup.
///
/// Commands capabilities are minted for exactly one deployment. The command
/// registry remains authoritative for whether that deployment or command is
/// still served by this manager.
pub fn sender_request_decision(subject: &Subject, deployment_id: &str) -> Option<bool> {
let Scope::Commands {
deployment_id: scoped_deployment_id,
capability,
..
} = &subject.scope
else {
return None;
};

Some(
subject.role == Role::CommandCapability
&& matches!(capability, CommandCapability::Send)
&& scoped_deployment_id == deployment_id,
)
}

/// Decide receiver access from the signed deployment and target before entity lookup.
///
/// The short-lived capability is already bound to the manager that accepted it.
/// Registry operations remain authoritative when a queued command exists; an
/// idle receiver discovers reassignment when its capability refreshes.
pub fn receiver_request_decision(
subject: &Subject,
deployment_id: &str,
requested_target: &CommandTarget,
) -> Option<bool> {
let Scope::Commands {
deployment_id: scoped_deployment_id,
capability,
..
} = &subject.scope
else {
return None;
};

Some(
subject.role == Role::CommandCapability
&& matches!(
capability,
CommandCapability::Receive { target } if target == requested_target
)
&& scoped_deployment_id == deployment_id,
)
}

/// Decide dispatch/read access when the subject carries a commands scope.
///
/// `None` means the subject is not commands-scoped and the caller should apply
/// its normal OSS or SaaS policy. Commands-scoped subjects always return a
/// definitive allow/deny decision and must never fall through to broader
/// deployment permissions.
pub fn sender_deployment_decision(
subject: &Subject,
deployment: &DeploymentRecord,
) -> Option<bool> {
let Scope::Commands {
project_id,
deployment_id,
capability,
} = &subject.scope
else {
return None;
};

Some(
subject.role == Role::CommandCapability
&& matches!(capability, CommandCapability::Send)
&& subject.workspace_id == deployment.workspace_id
&& project_id == &deployment.project_id
&& deployment_id == &deployment.id,
)
}

/// Decide command-record read access for a commands-scoped sender.
pub fn sender_context_decision(subject: &Subject, command: &CommandAccessContext) -> Option<bool> {
let Scope::Commands {
project_id,
deployment_id,
capability,
} = &subject.scope
else {
return None;
};

Some(
subject.role == Role::CommandCapability
&& matches!(capability, CommandCapability::Send)
&& subject.workspace_id == command.workspace_id
&& project_id == &command.project_id
&& deployment_id == &command.deployment_id,
)
}

/// Decide lease access when the subject carries a commands scope.
pub fn receiver_deployment_decision(
subject: &Subject,
deployment: &DeploymentRecord,
requested_target: &CommandTarget,
) -> Option<bool> {
let Scope::Commands {
project_id,
deployment_id,
capability,
} = &subject.scope
else {
return None;
};

Some(
subject.role == Role::CommandCapability
&& matches!(
capability,
CommandCapability::Receive { target } if target == requested_target
)
&& subject.workspace_id == deployment.workspace_id
&& project_id == &deployment.project_id
&& deployment_id == &deployment.id,
)
}

/// Authorize response and release operations from canonical command data.
pub fn receiver_context_allowed(subject: &Subject, command: &CommandAccessContext) -> bool {
matches!(
&subject.scope,
Scope::Commands {
project_id,
deployment_id,
capability: CommandCapability::Receive { target },
} if subject.role == Role::CommandCapability
&& subject.workspace_id == command.workspace_id
&& project_id == &command.project_id
&& deployment_id == &command.deployment_id
&& target == &command.target
)
}
3 changes: 2 additions & 1 deletion crates/alien-manager/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
//! table; embedders inject their own when they need stricter policy.

pub mod authz;
pub mod command_capability;
pub mod subject;

pub use authz::{Authz, DeploymentCreateCtx};
pub use subject::{Role, Scope, Subject, SubjectKind};
pub use subject::{CommandCapability, Role, Scope, Subject, SubjectKind};
Loading
Loading