Skip to content
This repository has been archived by the owner on Dec 7, 2023. It is now read-only.

Commit

Permalink
feat(server): add privileges-based layered API access
Browse files Browse the repository at this point in the history
This commit builds on top of 411e8e2 to
provide a more layered authentication/authorization model, based on the
thoughts described in #39.

The goal of this commit is to transform the "all-or-nothing"
authentication model into one that allows for fine-grained access
control _and_ allow unauthenticated access to the system.

This creates a more friendly environment for people within an
organisation using Automaat for the first time. Instead of shoving them
a login dialog in their face on their first visit, the server allows
access to all GraphQL queries, to support a client that can show the
full capabilities of API.

Instead, a new privileges-based system is implemented that restricts
GraphQL mutation access based on a set of matching rules where each
mutation requires a specific privilege to be granted to a session.

For tasks, each task can define one or more labels, for which at least
one must match a session's privileges in order to run that task.

If a task defines no labels, the task can be run by unauthenticated
sessions. This allows for patterns where simple side-effect-free tasks
can be used by anyone in the organisation to retrieve vital information
via Automaat, without having to authenticate.
  • Loading branch information
JeanMertz committed Jul 23, 2019
1 parent 5552179 commit abd8f76
Show file tree
Hide file tree
Showing 18 changed files with 348 additions and 34 deletions.
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.

2 changes: 1 addition & 1 deletion src/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = "1.0"
# see: http://git.io/fjPnd
tera = { git = "https://github.com/Keats/tera.git", branch = "v1" }
uuid = { version = "0.7.0", features = ["v4"] }
uuid = { version = "0.7.0", features = ["v4", "serde"] }

[dependencies.processor-git-clone-v1]
package = "automaat-processor-git-clone"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE tasks DROP COLUMN labels;
DROP FUNCTION automaat_validate_label;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ALTER TABLE tasks ADD COLUMN labels Text[] NOT NULL DEFAULT '{}';

CREATE FUNCTION automaat_validate_label(txt Text[]) RETURNS boolean AS $$
SELECT bool_and (str ~ '^[a-z0-9_]+$') FROM unnest(txt) s(str);
$$ IMMUTABLE STRICT LANGUAGE SQL;

ALTER TABLE tasks ADD CONSTRAINT label_syntax CHECK (automaat_validate_label(labels));
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE sessions DROP COLUMN privileges;
DROP FUNCTION automaat_validate_privilege;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ALTER TABLE sessions ADD COLUMN privileges Text[] NOT NULL DEFAULT '{}';

CREATE FUNCTION automaat_validate_privilege(txt Text[]) RETURNS boolean AS $$
SELECT bool_and (str ~ '^[a-z0-9_]+$') FROM unnest(txt) s(str);
$$ IMMUTABLE STRICT LANGUAGE SQL;

ALTER TABLE sessions ADD CONSTRAINT privilege_syntax CHECK (automaat_validate_privilege(privileges));
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE sessions DROP CONSTRAINT sessions_token_key;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE sessions ADD UNIQUE (token);
19 changes: 19 additions & 0 deletions src/server/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ input CreateJobFromTaskInput {
variables: [JobVariableInput!]!
}

input CreateSessionInput {
privileges: [String!]
}

input CreateStepInput {
name: String!
description: String
Expand All @@ -18,6 +22,7 @@ input CreateStepInput {
input CreateTaskInput {
name: String!
description: String
labels: [String!]
variables: [CreateVariableInput!]!
steps: [CreateStepInput!]!
}
Expand Down Expand Up @@ -151,6 +156,8 @@ type MutationRoot {
createTask(task: CreateTaskInput!): Task!
createJobFromTask(job: CreateJobFromTaskInput!): Job!
createGlobalVariable(variable: GlobalVariableInput!): Boolean!
createSession(session: CreateSessionInput!): String!
updatePrivileges(privileges: UpdatePrivilegesInput!): Session!
}

enum OnConflict {
Expand Down Expand Up @@ -191,6 +198,7 @@ type QueryRoot {
jobs: [Job!]!
task(id: ID!): Task
job(id: ID!): Job
session: Session
}

type RedisCommand {
Expand All @@ -210,6 +218,11 @@ input SearchTaskInput {
description: String
}

type Session {
id: ID!
privileges: [String!]!
}

type ShellCommand {
command: String!
arguments: [String!]
Expand Down Expand Up @@ -266,10 +279,16 @@ type Task {
id: ID!
name: String!
description: String
labels: [String!]!
variables: [Variable!]
steps: [Step!]
}

input UpdatePrivilegesInput {
id: ID!
privileges: [String!]!
}

scalar Url

type Variable {
Expand Down
159 changes: 151 additions & 8 deletions src/server/src/graphql.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::models::{NewGlobalVariable, Session};
use crate::models::{NewGlobalVariable, NewSession, Session};
use crate::resources::{
CreateJobFromTaskInput, CreateTaskInput, GlobalVariableInput, Job, NewJob, NewJobVariable,
NewTask, OnConflict, SearchTaskInput, Task,
CreateJobFromTaskInput, CreateSessionInput, CreateTaskInput, GlobalVariableInput, Job, NewJob,
NewJobVariable, NewTask, OnConflict, SearchTaskInput, Task, UpdatePrivilegesInput,
};
use crate::schema::*;
use crate::server::RequestState;
Expand Down Expand Up @@ -69,12 +69,24 @@ impl QueryRoot {
.optional()
.map_err(Into::into)
}

/// Get details of the current session, if any.
fn session(context: &RequestState) -> Option<&Session> {
context.session.as_ref()
}
}

#[object(Context = RequestState)]
impl MutationRoot {
/// Create a new task.
///
/// # Privileges
///
/// This mutation requires the `mutation_create_task` privilege to be set
/// for the provided session.
fn createTask(context: &RequestState, task: CreateTaskInput) -> FieldResult<Task> {
authorization_guard(&["mutation_create_task"], &context.session)?;

NewTask::try_from(&task)?
.create(&context.conn)
.map_err(Into::into)
Expand All @@ -83,11 +95,26 @@ impl MutationRoot {
/// Create a job from an existing task ID.
///
/// Once the job is created, it will be scheduled to run immediately.
///
/// # Privileges
///
/// This mutation supports both creating jobs from unauthenticated sessions,
/// and authenticated ones.
///
/// If a task has no labels attached, then anyone can create jobs for that
/// task. If a task has one or more labels, then an authenticated session
/// must exist, and at least one privilege must match one of the task
/// labels.
fn createJobFromTask(context: &RequestState, job: CreateJobFromTaskInput) -> FieldResult<Job> {
let task = tasks::table
let task: Task = tasks::table
.filter(tasks::id.eq(job.task_id.parse::<i32>()?))
.first(&context.conn)?;

authorization_guard(
&task.labels.iter().map(String::as_str).collect::<Vec<_>>(),
&context.session,
)?;

let variables = job
.variables
.iter()
Expand All @@ -105,12 +132,19 @@ impl MutationRoot {
/// By default, this mutation will return an error if the variable key
/// already exists. If you want to override an existing key, set the
/// `onConflict` key to `UPDATE`.
///
/// # Privileges
///
/// This mutation requires the `mutation_create_global_variable` privilege
/// to be set for the provided session.
fn createGlobalVariable(
context: &RequestState,
variable: GlobalVariableInput,
) -> FieldResult<bool> {
use OnConflict::*;

authorization_guard(&["mutation_create_global_variable"], &context.session)?;

let global_variable = NewGlobalVariable::from(&variable);
let global_variable = match &variable.on_conflict.as_ref().unwrap_or(&Abort) {
Abort => global_variable.create(&context.conn),
Expand All @@ -125,11 +159,120 @@ impl MutationRoot {
/// The returned session token can be used to authenticate with the GraphQL
/// API by using the `Authorization` header.
///
/// In the future, this token can also be used to store user-related
/// preferences.
fn createSession(context: &RequestState) -> FieldResult<String> {
Session::create(&context.conn)
/// # Privileges
///
/// This mutation requires the `mutation_create_session` privilege to
/// be set for the provided session.
fn createSession(context: &RequestState, session: CreateSessionInput) -> FieldResult<String> {
authorization_guard(&["mutation_create_session"], &context.session)?;

NewSession::from(&session)
.create(&context.conn)
.map(|s| s.token.to_string())
.map_err(Into::into)
}

/// Update the set of privileges of an existing session.
///
/// The privileges defined in this update will be set as-is as the new
/// session privileges. This means that any privileges existing before, but
/// missing in this update will be removed.
///
/// In other words, if you want to _add_ an extra privilege to an existing
/// set of privileges, you will first have to fetch all privileges of the
/// session, add the new privilege, and then run this mutation.
///
/// # Privileges
///
/// This mutation requires the `mutation_update_privileges` privilege to be
/// set for the provided session.
fn updatePrivileges(
context: &RequestState,
privileges: UpdatePrivilegesInput,
) -> FieldResult<Session> {
authorization_guard(&["mutation_update_privileges"], &context.session)?;

let session = sessions::table.filter(sessions::id.eq(privileges.id.parse::<i32>()?));

diesel::update(session)
.set(sessions::privileges.eq(privileges.privileges))
.get_result(&context.conn)
.map_err(Into::into)
}
}

/// A guard function that returns an error if none of the defined labels are
/// present in the provided session privileges.
///
/// If no labels are provided, the request is considered to be authorized.
///
/// If no session is provided, its privileges are considered to be empty.
fn authorization_guard(labels: &[&str], session: &Option<Session>) -> FieldResult<()> {
if labels.is_empty() {
return Ok(());
}

for label in labels {
if session
.as_ref()
.map_or(&vec![], |s| &s.privileges)
.iter()
.any(|x| x == label)
{
return Ok(());
}
}

Err("Unauthorized".into())
}

#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;

fn stub_session(privileges: &[&str]) -> Option<Session> {
Some(Session {
id: 0,
token: Uuid::new_v4(),
privileges: privileges.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
})
}

#[test]
fn test_authorization_guard_empty_labels() {
let session = stub_session(&[]);
authorization_guard(&[], &session).unwrap();
}

#[test]
#[should_panic]
fn test_authorization_guard_no_session() {
authorization_guard(&["required"], &None).unwrap();
}

#[test]
fn test_authorization_matching_privilege() {
let session = stub_session(&["required"]);
authorization_guard(&["required"], &session).unwrap();
}

#[test]
fn test_authorization_one_matching_guard() {
let session = stub_session(&["one"]);
authorization_guard(&["one", "two"], &session).unwrap();
}

#[test]
fn test_authorization_one_matching_privilege() {
let session = stub_session(&["two", "three"]);
authorization_guard(&["two"], &session).unwrap();
}

#[test]
#[should_panic]
fn test_authorization_no_matching_privilege() {
let session = stub_session(&["three"]);
authorization_guard(&["one", "two"], &session).unwrap();
}
}
27 changes: 15 additions & 12 deletions src/server/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ pub(super) fn graphql(

block(move || {
let conn = state.pool.get()?;
let session = authenticate(&token?, &conn)?;
let response = graphql.execute(&schema, &RequestState::new(conn, session));
let session = match token {
None => None,
Some(token) => Some(authenticate(&token?, &conn)?),
};

let response = graphql.execute(&schema, &RequestState::new(conn, session));
serde_json::to_string(&response).map_err(Into::<ServerError>::into)
})
.map_err(Into::into)
Expand Down Expand Up @@ -84,19 +87,20 @@ fn authenticate(token: &str, conn: &PgConnection) -> Result<Session, ServerError
.ok_or(ServerError::Authentication)
}

fn auth_token(request: &HttpRequest) -> Result<String, ServerError> {
fn auth_token(request: &HttpRequest) -> Option<Result<String, ServerError>> {
use actix_web::http::header;

request
.headers()
.get(header::AUTHORIZATION)
.and_then(|h| h.to_str().map(str::to_owned).ok())
.ok_or(ServerError::Authentication)
request.headers().get(header::AUTHORIZATION).map(|h| {
h.to_str()
.map(str::to_owned)
.map_err(|_| ServerError::Authentication)
})
}

#[cfg(test)]
mod tests {
use super::*;
use crate::models::NewSession;
use actix_web::test;
use diesel::prelude::*;
use diesel::result::Error;
Expand Down Expand Up @@ -126,7 +130,7 @@ mod tests {
let conn = connection();

conn.test_transaction::<_, Error, _>(|| {
let session = Session::create(&conn).unwrap();
let session = NewSession::new(vec![]).create(&conn).unwrap();
let auth = authenticate(&session.token.to_string(), &conn).unwrap();

assert_eq!(session.token, auth.token);
Expand All @@ -135,17 +139,16 @@ mod tests {
}

#[test]
#[should_panic]
fn test_auth_token_missing() {
let req = test::TestRequest::default().to_http_request();

let _ = auth_token(&req).unwrap();
assert!(auth_token(&req).is_none());
}

#[test]
fn test_auth_token_exists() {
let req = test::TestRequest::with_header("authorization", "token").to_http_request();

let _ = auth_token(&req).unwrap();
let _ = auth_token(&req).unwrap().unwrap();
}
}
2 changes: 1 addition & 1 deletion src/server/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ mod session;
mod variable_advertisement;

pub(crate) use global_variable::{GlobalVariable, NewGlobalVariable};
pub(crate) use session::Session;
pub(crate) use session::{NewSession, Session};
pub(crate) use variable_advertisement::{NewVariableAdvertisement, VariableAdvertisement};
Loading

0 comments on commit abd8f76

Please sign in to comment.