diff --git a/tinytodo/Cargo.toml b/tinytodo/Cargo.toml index a06851c..dc55b4e 100644 --- a/tinytodo/Cargo.toml +++ b/tinytodo/Cargo.toml @@ -21,6 +21,7 @@ notify = { version = "5.1.0", default-features = false, features = ["macos_kqueu use-templates = [] [dependencies.cedar-policy] +features = ["partial-eval"] version = "4.0.0" git = "https://github.com/cedar-policy/cedar" branch = "main" diff --git a/tinytodo/entities.json b/tinytodo/entities.json index f20de8f..0f3cca0 100644 --- a/tinytodo/entities.json +++ b/tinytodo/entities.json @@ -41,18 +41,27 @@ "teams": { "Team::\"temp\"": { "uid": "Team::\"temp\"", + "owner": "User::\"emina\"", + "admins": ["User::\"emina\""], + "name": "temp", "parents": [ "Application::\"TinyTodo\"" ] }, "Team::\"admin\"": { "uid": "Team::\"admin\"", + "owner": "User::\"emina\"", + "admins": ["User::\"emina\""], + "name": "admin", "parents": [ "Application::\"TinyTodo\"" ] }, "Team::\"interns\"": { "uid": "Team::\"interns\"", + "owner": "User::\"emina\"", + "admins": ["User::\"emina\""], + "name": "interns", "parents": [ "Application::\"TinyTodo\"", "Team::\"temp\"" diff --git a/tinytodo/frontend/static/hello.css b/tinytodo/frontend/static/hello.css new file mode 100644 index 0000000..789e04e --- /dev/null +++ b/tinytodo/frontend/static/hello.css @@ -0,0 +1,28 @@ + +ul.navbar { + list-style: none +} + +ul.navbar li { + display: inline-block; + padding: 0 7px; + position: relative; +} + +ul.navbar li:not(:last-child)::after { + content: ""; + border: 1px solid #e2e2e2; + border-width: 1px 1px 0 0 ; + position: absolute; + right: -3px; + top: 0; + height: 100%; +} + +.inlineSubmit { + background: none; + border: none; + color: blue; + text-decoration: underline; + cursor: pointer; +} diff --git a/tinytodo/frontend/templates/index.html b/tinytodo/frontend/templates/index.html new file mode 100644 index 0000000..85eb4d6 --- /dev/null +++ b/tinytodo/frontend/templates/index.html @@ -0,0 +1,5 @@ +{% extends "main.html" %} + {% block title %}Home{% endblock %} + {% block body %} + Tiny Todo Home + {% endblock %} \ No newline at end of file diff --git a/tinytodo/frontend/templates/list/create.html b/tinytodo/frontend/templates/list/create.html new file mode 100644 index 0000000..053f9e4 --- /dev/null +++ b/tinytodo/frontend/templates/list/create.html @@ -0,0 +1,10 @@ +{% extends "main.html" %} +{% block title %}Create List{% endblock %} +{% block body %} +

Create A List

+
+

+

+
+ +{% endblock %} \ No newline at end of file diff --git a/tinytodo/frontend/templates/list/index.html b/tinytodo/frontend/templates/list/index.html new file mode 100644 index 0000000..4188d10 --- /dev/null +++ b/tinytodo/frontend/templates/list/index.html @@ -0,0 +1,101 @@ +{% extends "main.html" %} +{% block title %} List Viewer {% endblock %} +{% block body %} + +

{{name}}

+

Contents

+ +

Add item:

+
+

+

+

+
+

Sharing

+Add reader: +
+

+

+

+

+
+Add editor: +
+

+

+

+

+
+ + + +{% endblock %} \ No newline at end of file diff --git a/tinytodo/frontend/templates/lists.html b/tinytodo/frontend/templates/lists.html new file mode 100644 index 0000000..78939ad --- /dev/null +++ b/tinytodo/frontend/templates/lists.html @@ -0,0 +1,15 @@ +{% extends "main.html" %} +{% block title %} +Lists +{% endblock %} +{% block body %} +Create a new list +

My Lists

+ +{% endblock %} \ No newline at end of file diff --git a/tinytodo/frontend/templates/login.html b/tinytodo/frontend/templates/login.html new file mode 100644 index 0000000..267b938 --- /dev/null +++ b/tinytodo/frontend/templates/login.html @@ -0,0 +1,8 @@ +{% extends "main.html" %} +{% block title %} Login {% endblock %} +{% block body %} +
+

+

+
+{% endblock %} \ No newline at end of file diff --git a/tinytodo/frontend/templates/main.html b/tinytodo/frontend/templates/main.html new file mode 100644 index 0000000..730c197 --- /dev/null +++ b/tinytodo/frontend/templates/main.html @@ -0,0 +1,30 @@ + + + + + >TinyTodo - {% block title %}{% endblock %} + + + + + {% block body %} {% endblock %} + + \ No newline at end of file diff --git a/tinytodo/frontend/templates/team/create.html b/tinytodo/frontend/templates/team/create.html new file mode 100644 index 0000000..73a0283 --- /dev/null +++ b/tinytodo/frontend/templates/team/create.html @@ -0,0 +1,10 @@ +{% extends "main.html" %} +{% block title %}Create List{% endblock %} +{% block body %} +

Create A Team

+
+

+

+
+ +{% endblock %} \ No newline at end of file diff --git a/tinytodo/frontend/templates/team/index.html b/tinytodo/frontend/templates/team/index.html new file mode 100644 index 0000000..48f00aa --- /dev/null +++ b/tinytodo/frontend/templates/team/index.html @@ -0,0 +1,38 @@ +{% extends "main.html" %} +{% block title %} List Viewer {% endblock %} +{% block body %} + +

{{name}}

+ +

Admins

+ +

Add Admin:

+
+

+

+

+
+ +

Remove Admin:

+
+

+

+

+
+ +

Members

+ +

Add Member:

+
+

+

+

+
+ +

Remove Member:

+
+

+

+

+
+{% endblock %} \ No newline at end of file diff --git a/tinytodo/frontend/templates/teams.html b/tinytodo/frontend/templates/teams.html new file mode 100644 index 0000000..f2288f1 --- /dev/null +++ b/tinytodo/frontend/templates/teams.html @@ -0,0 +1,24 @@ +{% extends "main.html" %} + +{% block title %} My Teams {% endblock %} +{% block body %} +

My Teams

+Create a new team +

Teams you can add/remove admins

+ +

Teams you can add/remove members

+ + +{% endblock %} \ No newline at end of file diff --git a/tinytodo/frontend/tinytodo-web.py b/tinytodo/frontend/tinytodo-web.py new file mode 100644 index 0000000..0a2d6f9 --- /dev/null +++ b/tinytodo/frontend/tinytodo-web.py @@ -0,0 +1,218 @@ +import flask +import requests +import secrets + +APP = flask.Flask(__name__) +# Lol don't do this +APP.secret_key = secrets.token_bytes(16) + +def render_page(page, **kwargs): + if 'username' in flask.session: + print('is logged in') + return flask.render_template(page, user=flask.session['username'], **kwargs) + else: + return flask.render_template(page, **kwargs) + +@APP.route("/") +def index(): + return render_page('index.html') + +@APP.route('/login', methods=['GET', 'POST']) +def login(): + if flask.request.method == 'POST': + username = flask.request.form['username'] + flask.session['username'] = username + return flask.redirect(flask.url_for('index')) + else: + return render_page('login.html') + +@APP.route("/logout") +def logout(): + flask.session.pop('username', None) + return flask.redirect(flask.url_for('index')) + +@APP.route('/lists/') +def get_lists(): + r = requests.get('http://localhost:8080/api/lists/get', params= {'uid': 'User::"{0}"'.format(flask.session['username'])}) + return render_page('lists.html', lists=r.json()) + +@APP.route('/teams') +def get_teams(): + r = requests.get('http://localhost:8080/api/team/manage/member/get', params= {'uid': 'User::"{0}"'.format(flask.session['username'])}) + member_teams = list(filter(lambda x: x['name'] is not None, r.json())) + r = requests.get('http://localhost:8080/api/team/manage/admin/get', params= {'uid': 'User::"{0}"'.format(flask.session['username'])}) + admin_teams = list(filter(lambda x: x['name'] is not None, r.json())) + return render_page('teams.html', member_teams=member_teams, admin_teams=admin_teams) + +@APP.route('/list/create', methods=['GET']) +def get_create_list(): + return render_page('list/create.html') + +@APP.route('/list/create', methods=['POST']) +def post_create_list(): + try: + name = flask.request.form['name'] + owner = 'User::"{0}"'.format(flask.session['username']) + data = {'uid': owner, 'name': name} + requests.post('http://localhost:8080/api/list/create', json=data) + return flask.redirect(f'/lists') + except KeyError: + return 'Bad args!', 403 + +@APP.route('/list/read') +def get_list(): + list = flask.request.args['name'] + r = requests.get('http://localhost:8080/api/list/get', params= {'uid': 'User::"{0}"'.format(flask.session['username']), 'list': list}) + l = r.json() + return render_page('list/index.html', id=l['uid'], name=l['name'], items=l['tasks'], readers=l['readers'], editors=l['editors']) + +@APP.route('/list/add_item', methods=['POST']) +def add_item(): + user = 'User::"{0}"'.format(flask.session['username']) + list_uid = flask.request.form['name'] + task_name = flask.request.form['item'] + data = { + 'uid': user, + 'list': list_uid, + 'name': task_name, + } + r = requests.post('http://localhost:8080/api/task/create', json=data) + if r.status_code == 200: + body = r.json() + if is_error(body): + if is_authz_denied(body): + return flask.redirect(f'/auth_denied') + else: + print('Error: %s' % body['error']) + return flask.redirect(f'/lists') + +@APP.route('/list/delete_task', methods=['POST']) +def delete_item(): + user = 'User::"{0}"'.format(flask.session['username']) + list_uid = flask.request.form['name'] + task_name = flask.request.form['task_id'] + data = { + 'uid': user, + 'list': list_uid, + 'task': task_name, + } + r = requests.delete('http://localhost:8080/api/task/delete', json=data) + print(r) + print(data) + if r.status_code == 200: + body = r.json() + if is_error(body): + if is_authz_denied(body): + return flask.redirect(f'/auth_denied') + else: + print('Error: %s' % body['error']) + return flask.redirect(f'/lists') + +@APP.route('/list/share_with', methods = ['POST']) +def share_with(): + user= 'User::"{0}"'.format(flask.request.form['user']) + data = { + 'uid': 'User::"{0}"'.format(flask.session['username']), + 'list': flask.request.form['list_id'], + 'share_with': user, + 'role': flask.request.form['share_kind'] + } + r = requests.post('http://localhost:8080/api/share', json=data) + if r.status_code == 200: + body = r.json() + if is_error(body): + if is_authz_denied(body): + return flask.redirect(f'/auth_denied') + else: + print('Error: %s' % body['error']) + return flask.redirect(f'/lists') + +@APP.route('/team/create', methods=['GET']) +def get_create_team(): + return render_page('team/create.html') + +@APP.route('/team/create', methods=['POST']) +def post_create_team(): + try: + id = flask.request.form['name'] + owner = flask.session['username'] + data = {'owner': owner, 'id': id} + requests.post('http://localhost:8080/api/team/create', json=data) + return flask.redirect(f'/teams') + except KeyError: + return 'Bad args!', 403 + +@APP.route('/auth_denied') +def auth_denied(): + return 'You are not authorized to perform this action', 403 + +@APP.route('/team/read') +def get_team(): + uid = flask.request.args['uid'] + r = requests.get('http://localhost:8080/api/team/get', params= {'uid': uid}) + l = r.json() + return render_page('team/index.html', id=l['uid'], name=l['name'],) + +@APP.route('/team/remove_admin', methods=['POST']) +def remove_admin(): + data = { + 'team': flask.request.form['name'], + 'user': flask.session['username'], + 'candidate': flask.request.form['candidate'] + } + r = requests.delete('http://localhost:8080/api/team/admin/remove', json=data) + if r.status_code == 200: + body = r.json() + if is_error(body): + if is_authz_denied(body): + return flask.redirect(f'/auth_denied') + else: + print('Error: %s' % body['error']) + return flask.redirect(f'/teams') + +@APP.route('/team/add_admin', methods=['POST']) +def add_admin(): + data = { + 'team': flask.request.form['name'], + 'user': flask.session['username'], + 'candidate': flask.request.form['candidate'] + } + requests.post('http://localhost:8080/api/team/admin/add', json=data) + return flask.redirect(f'/teams') + +@APP.route('/team/remove_member', methods=['POST']) +def remove_member(): + data = { + 'team': flask.request.form['name'], + 'user': flask.session['username'], + 'candidate': flask.request.form['candidate'] + } + r = requests.delete('http://localhost:8080/api/team/member/remove', json=data) + if r.status_code == 200: + body = r.json() + if is_error(body): + if is_authz_denied(body): + return flask.redirect(f'/auth_denied') + else: + print('Error: %s' % body['error']) + return flask.redirect(f'/teams') + +@APP.route('/team/add_member', methods=['POST']) +def add_member(): + data = { + 'team': flask.request.form['name'], + 'user': flask.session['username'], + 'candidate': flask.request.form['candidate'] + } + requests.post('http://localhost:8080/api/team/member/add', json=data) + return flask.redirect(f'/teams') + +def is_error(body): + return type(body) is dict and 'error' in body + +def is_authz_denied(body): + return 'error' in body and body['error'] == 'Authorization Denied' + +if __name__ == '__main__': + APP.debug = True + APP.run() diff --git a/tinytodo/policies.cedar b/tinytodo/policies.cedar index b33e1f6..697d1bd 100644 --- a/tinytodo/policies.cedar +++ b/tinytodo/policies.cedar @@ -1,16 +1,12 @@ // Policy 0: Any User can create a list and see what lists they own permit ( principal, - action in [Action::"CreateList", Action::"GetLists"], + action in [Action::"CreateList", Action::"GetLists", Action::"CreateTeam"], resource == Application::"TinyTodo" ); // Policy 1: A User can perform any action on a List they own -permit ( - principal, - action, - resource is List -) +permit (principal, action, resource is List) when { resource.owner == principal }; // Policy 2: A User can see a List if they are either a reader or editor @@ -40,7 +36,7 @@ when { principal in resource.editors }; // action, // resource in Application::"TinyTodo" // ); -// +// // Policy 5: Interns may not create new task lists // forbid ( // principal in Team::"interns", @@ -48,7 +44,7 @@ when { principal in resource.editors }; // resource == Application::"TinyTodo" // ); // -// Policy 6: No access if not high rank and at location DEF, +// Policy 6: No access if not high rank and at location DEF, // or at resource's owner's location // forbid( // principal, @@ -57,4 +53,40 @@ when { principal in resource.editors }; // ) unless { // principal.joblevel > 6 && principal.location like "DEF*" || // principal.location == resource.owner.location -// }; \ No newline at end of file +// }; + +// Only team owners can add admins +permit ( + principal, + action == Action::"AddAdmin", + resource +) +when { resource.owner == principal }; + +// Only team owners can remove admins but they can't remove themselves +permit ( + principal, + action == Action::"RemoveAdmin", + resource +) +when { resource.owner == principal && context.candidate != principal }; + +// Only team admins can add members +permit ( + principal, + action == Action::"AddMember", + resource +) +when { resource.admins.contains(principal) }; + +// Only team admins can remove members but they cannot remove other admins +permit ( + principal, + action == Action::"RemoveMember", + resource +) +when +{ + resource.admins.contains(principal) && + !resource.admins.contains(context.candidate) +}; \ No newline at end of file diff --git a/tinytodo/src/api.rs b/tinytodo/src/api.rs index dc946ec..a15be0e 100644 --- a/tinytodo/src/api.rs +++ b/tinytodo/src/api.rs @@ -22,12 +22,111 @@ use warp::Filter; use crate::{ context::{AppQuery, AppQueryKind, AppResponse, Error}, - objects::{List, TaskState}, - util::{EntityUid, ListUid, UserOrTeamUid, UserUid}, + objects::{List, TaskState, Team, User}, + util::{EntityUid, ListUid, TeamUid, UserOrTeamUid, UserUid}, }; type AppChannel = mpsc::Sender; +#[derive(Debug, Clone, Deserialize)] +pub struct CreateUser { + pub id: String, + pub joblevel: i64, + pub location: String, +} + +impl From for AppQueryKind { + fn from(v: CreateUser) -> AppQueryKind { + AppQueryKind::CreateUser(v) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GetUser { + pub id: String, +} + +impl From for AppQueryKind { + fn from(v: GetUser) -> AppQueryKind { + AppQueryKind::GetUser(v) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateTeam { + pub owner: String, + pub id: String, +} + +impl From for AppQueryKind { + fn from(v: CreateTeam) -> AppQueryKind { + AppQueryKind::CreateTeam(v) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GetTeam { + pub uid: TeamUid, +} + +impl From for AppQueryKind { + fn from(v: GetTeam) -> AppQueryKind { + AppQueryKind::GetTeam(v) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AddAdmin { + pub team: TeamUid, + pub user: String, + pub candidate: String, +} + +impl From for AppQueryKind { + fn from(value: AddAdmin) -> Self { + AppQueryKind::AddAdmin(value) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RemoveAdmin { + pub team: TeamUid, + pub user: String, + pub candidate: String, +} + +impl From for AppQueryKind { + fn from(value: RemoveAdmin) -> Self { + AppQueryKind::RemoveAdmin(value) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AddMember { + pub team: String, + pub user: String, + pub candidate: String, +} + +impl From for AppQueryKind { + fn from(value: AddMember) -> Self { + AppQueryKind::AddMember(value) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RemoveMember { + pub team: String, + pub user: String, + pub candidate: String, +} + +impl From for AppQueryKind { + fn from(value: RemoveMember) -> Self { + AppQueryKind::RemoveMember(value) + } +} + #[derive(Debug, Clone, Deserialize)] pub struct GetList { pub uid: UserUid, @@ -122,6 +221,28 @@ impl From for AppQueryKind { } } +#[derive(Debug, Clone, Deserialize)] +pub struct GetMemberTeams { + pub uid: UserUid, +} + +impl From for AppQueryKind { + fn from(v: GetMemberTeams) -> AppQueryKind { + AppQueryKind::GetMemberTeams(v) + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct GetAdminTeams { + pub uid: UserUid, +} + +impl From for AppQueryKind { + fn from(v: GetAdminTeams) -> AppQueryKind { + AppQueryKind::GetAdminTeams(v) + } +} + #[derive(Debug, Clone, Deserialize)] pub struct UpdateTask { pub uid: UserUid, @@ -178,26 +299,26 @@ pub async fn serve_api(chan: AppChannel, port: u16) { let filter = warp::path("api").and( // List CRUD (warp::path("list").and( - (warp::path("get") + warp::path("get") .and(warp::get()) .and(with_app(chan.clone())) .and(warp::query::query::()) - .and_then(simple_query::)) - .or(warp::path("create") - .and(warp::post()) - .and(with_app(chan.clone())) - .and(warp::body::json()) - .and_then(simple_query::)) - .or(warp::path("update") - .and(warp::post()) - .and(with_app(chan.clone())) - .and(warp::body::json()) - .and_then(simple_query::)) - .or(warp::path("delete") - .and(warp::delete()) - .and(with_app(chan.clone())) - .and(warp::body::json()) - .and_then(simple_query::)), + .and_then(simple_query::) + .or(warp::path("create") + .and(warp::post()) + .and(with_app(chan.clone())) + .and(warp::body::json()) + .and_then(simple_query::)) + .or(warp::path("update") + .and(warp::post()) + .and(with_app(chan.clone())) + .and(warp::body::json()) + .and_then(simple_query::)) + .or(warp::path("delete") + .and(warp::delete()) + .and(with_app(chan.clone())) + .and(warp::body::json()) + .and_then(simple_query::)), )) .or( // Task CRUD @@ -233,6 +354,64 @@ pub async fn serve_api(chan: AppChannel, port: u16) { .and(with_app(chan.clone())) .and(warp::body::json()) .and_then(simple_query::)), + )) + .or(warp::path("user").and( + warp::path("create") + .and(warp::post()) + .and(with_app(chan.clone())) + .and(warp::body::json()) + .and_then(simple_query::) + .or(warp::path("get") + .and(warp::get()) + .and(with_app(chan.clone())) + .and(warp::query::query::()) + .and_then(simple_query::)), + )) + .or(warp::path("team").and( + (warp::path("create") + .and(warp::post()) + .and(with_app(chan.clone())) + .and(warp::body::json()) + .and_then(simple_query::)) + .or(warp::path("get") + .and(warp::get()) + .and(with_app(chan.clone())) + .and(warp::query::query::()) + .and_then(simple_query::)) + .or(warp::path("admin").and( + (warp::path("add") + .and(warp::post()) + .and(with_app(chan.clone())) + .and(warp::body::json()) + .and_then(simple_query::)) + .or(warp::path("remove") + .and(warp::delete()) + .and(with_app(chan.clone())) + .and(warp::body::json()) + .and_then(simple_query::)), + )) + .or(warp::path("member").and( + (warp::path("add") + .and(warp::post()) + .and(with_app(chan.clone())) + .and(warp::body::json()) + .and_then(simple_query::)) + .or(warp::path("remove") + .and(warp::delete()) + .and(with_app(chan.clone())) + .and(warp::body::json()) + .and_then(simple_query::)), + )) + .or(warp::path("manage").and( + warp::path("member") + .and(with_app(chan.clone())) + .and(warp::query::query::()) + .and_then(simple_query::>) + .or(warp::path("admin") + .and(with_app(chan.clone())) + .and(warp::query::query::()) + .and_then(simple_query::>)), + )), )), ); @@ -292,7 +471,7 @@ where let kind = q.into(); let q = AppQuery::new(kind, send); app.send(q).await?; - let resp = recv.await??; + let resp: AppResponse = recv.await??; let resp = resp.try_into()?; Ok(resp) } diff --git a/tinytodo/src/context.rs b/tinytodo/src/context.rs index 8017569..9b97e27 100644 --- a/tinytodo/src/context.rs +++ b/tinytodo/src/context.rs @@ -16,12 +16,13 @@ use itertools::Itertools; use lazy_static::lazy_static; -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf, str::FromStr}; use tracing::{error, info, trace}; use cedar_policy::{ - Authorizer, Context, Decision, Diagnostics, HumanSchemaError, ParseErrors, PolicySet, - PolicySetError, Request, Schema, SchemaError, ValidationMode, Validator, + Authorizer, Context, Decision, Diagnostics, Entities, EntityId, HumanSchemaError, ParseErrors, + PartialResponse, PolicySet, PolicySetError, Request, RequestBuilder, RestrictedExpression, + Schema, SchemaError, ValidationMode, Validator, }; use thiserror::Error; @@ -32,21 +33,20 @@ use tokio::sync::{ use crate::{ api::{ - AddShare, CreateList, CreateTask, DeleteList, DeleteShare, DeleteTask, Empty, GetList, - GetLists, UpdateList, UpdateTask, + AddAdmin, AddMember, AddShare, CreateList, CreateTask, CreateTeam, CreateUser, DeleteList, + DeleteShare, DeleteTask, Empty, GetAdminTeams, GetList, GetLists, GetMemberTeams, GetTeam, + GetUser, RemoveAdmin, RemoveMember, UpdateList, UpdateTask, }, entitystore::{EntityDecodeError, EntityStore}, - objects::List, + objects::{List, Team, User}, policy_store, - util::{EntityUid, ListUid, TYPE_LIST}, + util::{EntityUid, ListUid, TeamUid, UserUid, TYPE_LIST, TYPE_TEAM, TYPE_USER}, }; #[cfg(feature = "use-templates")] use crate::{api::ShareRole, util::UserOrTeamUid}; #[cfg(feature = "use-templates")] use cedar_policy::{PolicyId, SlotId}; -#[cfg(feature = "use-templates")] -use std::collections::HashMap; // There's almost certainly a nicer way to do this than having separate `sender` fields @@ -57,6 +57,9 @@ pub enum AppResponse { Lists(Vec), TaskId(i64), Unit(()), + User(User), + Team(Team), + Teams(Vec), } impl AppResponse { @@ -108,6 +111,26 @@ impl TryInto for AppResponse { } } +impl TryInto for AppResponse { + type Error = Error; + fn try_into(self) -> std::result::Result { + match self { + AppResponse::User(u) => Ok(u), + _ => Err(Error::Type), + } + } +} + +impl TryInto for AppResponse { + type Error = Error; + fn try_into(self) -> std::result::Result { + match self { + AppResponse::Team(t) => Ok(t), + _ => Err(Error::Type), + } + } +} + impl TryInto> for AppResponse { type Error = Error; fn try_into(self) -> std::result::Result, Self::Error> { @@ -118,6 +141,16 @@ impl TryInto> for AppResponse { } } +impl TryInto> for AppResponse { + type Error = Error; + fn try_into(self) -> std::result::Result, Self::Error> { + match self { + AppResponse::Teams(l) => Ok(l), + _ => Err(Error::Type), + } + } +} + #[derive(Debug)] pub enum AppQueryKind { // List CRUD @@ -140,6 +173,20 @@ pub enum AppQueryKind { // Policy Set Updates UpdatePolicySet(PolicySet), + + // User CRUD + CreateUser(CreateUser), + GetUser(GetUser), + + // Team CRUD + CreateTeam(CreateTeam), + GetTeam(GetTeam), + AddAdmin(AddAdmin), + RemoveAdmin(RemoveAdmin), + AddMember(AddMember), + RemoveMember(RemoveMember), + GetMemberTeams(GetMemberTeams), + GetAdminTeams(GetAdminTeams), } #[derive(Debug)] @@ -178,6 +225,8 @@ pub enum ContextError { pub enum Error { #[error("No Such Entity: {0}")] NoSuchEntity(EntityUid), + #[error("Duplicate entity: {0}")] + DuplicateEntity(EntityUid), #[error("Entity Decode Error: {0}")] EntityDecode(#[from] EntityDecodeError), #[error("Authorization Denied")] @@ -217,6 +266,10 @@ lazy_static! { static ref ACTION_CREATE_LIST: EntityUid = r#"Action::"CreateList""#.parse().unwrap(); static ref ACTION_UPDATE_LIST: EntityUid = r#"Action::"UpdateList""#.parse().unwrap(); static ref ACTION_DELETE_LIST: EntityUid = r#"Action::"DeleteList""#.parse().unwrap(); + static ref ACTION_ADD_ADMIN: EntityUid = r#"Action::"AddAdmin""#.parse().unwrap(); + static ref ACTION_REMOVE_ADMIN: EntityUid = r#"Action::"RemoveAdmin""#.parse().unwrap(); + static ref ACTION_ADD_MEMBER: EntityUid = r#"Action::"AddMember""#.parse().unwrap(); + static ref ACTION_REMOVE_MEMBER: EntityUid = r#"Action::"RemoveMember""#.parse().unwrap(); } pub struct AppContext { @@ -355,6 +408,16 @@ impl AppContext { AppQueryKind::AddShare(r) => self.add_share(r), AppQueryKind::DeleteShare(r) => self.delete_share(r), AppQueryKind::UpdatePolicySet(set) => self.update_policy_set(set), + AppQueryKind::CreateUser(r) => self.create_user(r), + AppQueryKind::GetUser(r) => self.get_user(r), + AppQueryKind::CreateTeam(r) => self.create_team(r), + AppQueryKind::GetTeam(r) => self.get_team(r), + AppQueryKind::AddAdmin(r) => self.add_admin(r), + AppQueryKind::RemoveAdmin(r) => self.remove_admin(r), + AppQueryKind::AddMember(r) => self.add_member(r), + AppQueryKind::RemoveMember(r) => self.remove_member(r), + AppQueryKind::GetAdminTeams(r) => self.get_admin_teams(r), + AppQueryKind::GetMemberTeams(r) => self.get_member_teams(r), }; if let Err(e) = msg.sender.send(r) { trace!("Failed send response: {:?}", e); @@ -526,6 +589,330 @@ impl AppContext { )) } + fn reauthorize_with_concrete_resource( + &self, + partial_response: &PartialResponse, + euid: &EntityUid, + entities: &Entities, + ) -> bool { + matches!( + partial_response.reauthorize( + HashMap::from_iter( + std::iter::once( + ("resource".into(), RestrictedExpression::new_entity_uid(cedar_policy::EntityUid::from((*euid).clone()))) + ) + ), + &self.authorizer, + &entities), + Ok(r) if !matches!(r.decision(), Some(Decision::Deny))) + } + + fn get_admin_teams(&self, r: GetAdminTeams) -> Result { + let entities = self.entities.as_entities(&self.schema); + let partial_request_add = RequestBuilder::default() + .action(Some(ACTION_ADD_ADMIN.as_ref().clone().into())) + .principal(Some(cedar_policy::EntityUid::from(EntityUid::from( + r.uid.clone(), + )))) + .context( + Context::from_pairs(std::iter::once(( + "candidate".to_owned(), + RestrictedExpression::from_str(r#"unknown("candidate")"#).unwrap(), + ))) + .unwrap(), + ) + .build(); + let partial_request_remove = RequestBuilder::default() + .action(Some(ACTION_REMOVE_ADMIN.as_ref().clone().into())) + .principal(Some(cedar_policy::EntityUid::from(EntityUid::from( + r.uid.clone(), + )))) + .context( + Context::from_pairs(std::iter::once(( + "candidate".to_owned(), + RestrictedExpression::new_entity_uid( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + cedar_policy::EntityId::new(""), + ), + ), + ))) + .unwrap(), + ) + .build(); + let partial_response_add = + self.authorizer + .is_authorized_partial(&partial_request_add, &self.policies, &entities); + let partial_response_remove = self.authorizer.is_authorized_partial( + &partial_request_remove, + &self.policies, + &entities, + ); + + let slice = self.entities.get_teams(); + + Ok(AppResponse::Teams( + slice + .filter(|t| { + self.reauthorize_with_concrete_resource( + &partial_response_add, + &t.uid().clone().into(), + &entities, + ) || self.reauthorize_with_concrete_resource( + &partial_response_remove, + &t.uid().clone().into(), + &entities, + ) + }) + .cloned() + .collect::>(), + )) + } + + fn get_member_teams(&self, r: GetMemberTeams) -> Result { + let entities = self.entities.as_entities(&self.schema); + let partial_request_add = RequestBuilder::default() + .action(Some(ACTION_ADD_MEMBER.as_ref().clone().into())) + .principal(Some(cedar_policy::EntityUid::from(EntityUid::from( + r.uid.clone(), + )))) + .context( + Context::from_pairs(std::iter::once(( + "candidate".to_owned(), + RestrictedExpression::from_str(r#"unknown("candidate")"#).unwrap(), + ))) + .unwrap(), + ) + .build(); + let partial_request_remove = RequestBuilder::default() + .action(Some(ACTION_REMOVE_MEMBER.as_ref().clone().into())) + .principal(Some(cedar_policy::EntityUid::from(EntityUid::from( + r.uid.clone(), + )))) + .context( + Context::from_pairs(std::iter::once(( + "candidate".to_owned(), + RestrictedExpression::new_entity_uid( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + cedar_policy::EntityId::new(""), + ), + ), + ))) + .unwrap(), + ) + .build(); + let partial_response_add = + self.authorizer + .is_authorized_partial(&partial_request_add, &self.policies, &entities); + let partial_response_remove = self.authorizer.is_authorized_partial( + &partial_request_remove, + &self.policies, + &entities, + ); + + let slice = self.entities.get_teams(); + + Ok(AppResponse::Teams( + slice + .filter(|t| { + self.reauthorize_with_concrete_resource( + &partial_response_add, + &t.uid().clone().into(), + &entities, + ) || self.reauthorize_with_concrete_resource( + &partial_response_remove, + &t.uid().clone().into(), + &entities, + ) + }) + .cloned() + .collect::>(), + )) + } + + fn create_team(&mut self, r: CreateTeam) -> Result { + let u = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.owner), + ), + )) + .unwrap(); + let t = self + .entities + .fresh_euid::(TYPE_TEAM.clone()) + .unwrap(); + self.entities.insert_team(Team::new_w_name(t, &r.id, u)); + Ok(AppResponse::Unit(())) + } + + fn get_team(&mut self, r: GetTeam) -> Result { + let team_id = r.uid; + self.entities + .get_team(&team_id) + .map(|v| AppResponse::Team(v.clone())) + } + + fn add_admin(&mut self, r: AddAdmin) -> Result { + let team_id = r.team; + let user_id = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.user), + ), + )) + .unwrap(); + let candidate_id = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.candidate), + ), + )) + .unwrap(); + + self.is_authorized_with_context( + user_id, + &*ACTION_ADD_ADMIN, + team_id.clone(), + Context::from_pairs([("candidate".to_owned(), candidate_id.clone().into())]).unwrap(), + )?; + + self.entities.add_admin(team_id, candidate_id)?; + + Ok(AppResponse::Unit(())) + } + + fn remove_member(&mut self, r: RemoveMember) -> Result { + let team_id = TeamUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_TEAM.clone(), + EntityId::new(r.team), + ), + )) + .unwrap(); + let user_id = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.user), + ), + )) + .unwrap(); + let candidate_id = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.candidate), + ), + )) + .unwrap(); + + self.is_authorized_with_context( + user_id, + &*ACTION_REMOVE_MEMBER, + team_id.clone(), + Context::from_pairs([("candidate".to_owned(), candidate_id.clone().into())]).unwrap(), + )?; + + self.entities.remove_user_from_team(candidate_id, team_id)?; + + Ok(AppResponse::Unit(())) + } + + fn add_member(&mut self, r: AddMember) -> Result { + let team_id = TeamUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_TEAM.clone(), + EntityId::new(r.team), + ), + )) + .unwrap(); + let user_id = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.user), + ), + )) + .unwrap(); + let candidate_id = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.candidate), + ), + )) + .unwrap(); + + self.is_authorized_with_context( + user_id, + &*ACTION_ADD_MEMBER, + team_id.clone(), + Context::from_pairs([("candidate".to_owned(), candidate_id.clone().into())]).unwrap(), + )?; + + self.entities.add_user_to_team(candidate_id, team_id)?; + + Ok(AppResponse::Unit(())) + } + + fn remove_admin(&mut self, r: RemoveAdmin) -> Result { + let team_id = r.team; + let user_id = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.user), + ), + )) + .unwrap(); + let candidate_id = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.candidate), + ), + )) + .unwrap(); + + self.is_authorized_with_context( + user_id, + &*ACTION_REMOVE_ADMIN, + team_id.clone(), + Context::from_pairs([("candidate".to_owned(), candidate_id.clone().into())]).unwrap(), + )?; + + self.entities.remove_admin(team_id, candidate_id)?; + + Ok(AppResponse::Unit(())) + } + + fn create_user(&mut self, r: CreateUser) -> Result { + let u = User::new( + UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id( + TYPE_USER.clone(), + EntityId::new(r.id), + ), + )) + .unwrap(), + r.joblevel, + r.location, + ); + match self.entities.get_user(u.uid()) { + Ok(_) => Err(Error::DuplicateEntity(u.uid().clone().into())), + Err(_) => { + self.entities.insert_user(u); + Ok(AppResponse::Unit(())) + } + } + } + + fn get_user(&mut self, r: GetUser) -> Result { + let user_id = UserUid::try_from(EntityUid::from( + cedar_policy::EntityUid::from_type_name_and_id(TYPE_USER.clone(), EntityId::new(r.id)), + )) + .unwrap(); + self.entities + .get_user(&user_id) + .map(|v| AppResponse::User(v.clone())) + } + fn create_list(&mut self, r: CreateList) -> Result { self.is_authorized(&r.uid, &*ACTION_CREATE_LIST, &*APPLICATION_TINY_TODO)?; @@ -558,6 +945,37 @@ impl AppContext { Ok(AppResponse::Unit(())) } + #[tracing::instrument(skip_all)] + pub fn is_authorized_with_context( + &self, + principal: impl AsRef, + action: impl AsRef, + resource: impl AsRef, + context: cedar_policy::Context, + ) -> Result<()> { + let es = self.entities.as_entities(&self.schema); + let q = Request::new( + Some(principal.as_ref().clone().into()), + Some(action.as_ref().clone().into()), + Some(resource.as_ref().clone().into()), + context, + Some(&self.schema), + ) + .map_err(|e| Error::Request(e.to_string()))?; + info!( + "is_authorized request: principal: {}, action: {}, resource: {}", + principal.as_ref(), + action.as_ref(), + resource.as_ref() + ); + let response = self.authorizer.is_authorized(&q, &self.policies, &es); + info!("Auth response: {:?}", response); + match response.decision() { + Decision::Allow => Ok(()), + Decision::Deny => Err(Error::AuthDenied(response.diagnostics().clone())), + } + } + #[tracing::instrument(skip_all)] pub fn is_authorized( &self, diff --git a/tinytodo/src/entitystore.rs b/tinytodo/src/entitystore.rs index 2b6acbb..2671b06 100644 --- a/tinytodo/src/entitystore.rs +++ b/tinytodo/src/entitystore.rs @@ -41,6 +41,10 @@ impl EntityStore { self.lists.values() } + pub fn get_teams(&self) -> impl Iterator { + self.teams.values() + } + pub fn euids(&self) -> impl Iterator { self.users .keys() @@ -88,6 +92,26 @@ impl EntityStore { self.lists.insert(e.uid().clone().into(), e); } + pub fn add_admin(&mut self, e: TeamUid, c: UserUid) -> Result<(), Error> { + match self.teams.get_mut(&e.clone().into()) { + Some(t) => { + t.add_admin(c); + Ok(()) + } + None => Err(Error::no_such_entity(e)), + } + } + + pub fn remove_admin(&mut self, e: TeamUid, c: UserUid) -> Result<(), Error> { + match self.teams.get_mut(&e.clone().into()) { + Some(t) => { + t.remove_admin(c); + Ok(()) + } + None => Err(Error::no_such_entity(e)), + } + } + pub fn delete_entity(&mut self, e: impl AsRef) -> Result<(), Error> { let r = e.as_ref(); if self.users.contains_key(r) { @@ -155,6 +179,36 @@ impl EntityStore { .get_mut(euid.as_ref()) .ok_or_else(|| Error::no_such_entity(euid.clone())) } + + pub fn add_user_to_team(&mut self, candidate: UserUid, team: TeamUid) -> Result<(), Error> { + // TODO: `get_team` and `get_mut` should trivially succeed after + // successful authorization + let _ = self.get_team(&team)?; + match self.users.get_mut(&candidate.clone().into()) { + Some(user) => { + user.insert_parent(team); + Ok(()) + } + None => Err(Error::no_such_entity(candidate)), + } + } + + pub fn remove_user_from_team( + &mut self, + candidate: UserUid, + team: TeamUid, + ) -> Result<(), Error> { + // TODO: `get_team` and `get_mut` should trivially succeed after + // successful authorization + let _ = self.get_team(&team)?; + match self.users.get_mut(&candidate.clone().into()) { + Some(user) => { + user.delete_parent(&team); + Ok(()) + } + None => Err(Error::no_such_entity(candidate)), + } + } } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] diff --git a/tinytodo/src/objects.rs b/tinytodo/src/objects.rs index 872c042..41bb826 100644 --- a/tinytodo/src/objects.rs +++ b/tinytodo/src/objects.rs @@ -14,7 +14,7 @@ * limitations under the License. */ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use cedar_policy::{Entity, EvalResult, RestrictedExpression}; use serde::{Deserialize, Serialize}; @@ -115,14 +115,31 @@ impl UserOrTeam for User { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Team { uid: TeamUid, + name: Option, + owner: UserUid, + admins: HashSet, parents: HashSet, } impl Team { - pub fn new(euid: TeamUid) -> Team { + pub fn new_wo_name(euid: TeamUid, owner: UserUid) -> Team { + let parent = Application::default().euid().clone(); + Self { + uid: euid, + name: None, + owner: owner.clone(), + admins: [owner].into_iter().collect(), + parents: [parent].into_iter().collect(), + } + } + + pub fn new_w_name(euid: TeamUid, name: &str, owner: UserUid) -> Team { let parent = Application::default().euid().clone(); Self { uid: euid, + name: Some(name.to_owned()), + owner: owner.clone(), + admins: [owner].into_iter().collect(), parents: [parent].into_iter().collect(), } } @@ -130,15 +147,31 @@ impl Team { pub fn uid(&self) -> &TeamUid { &self.uid } + + pub fn add_admin(&mut self, candidate: UserUid) { + self.admins.insert(candidate); + } + + pub fn remove_admin(&mut self, candidate: UserUid) { + self.admins.remove(&candidate); + } } impl From for Entity { fn from(team: Team) -> Entity { let euid: EntityUid = team.uid.into(); - Entity::new_no_attrs( + Entity::new( euid.into(), + HashMap::from_iter([ + ( + "admins".to_owned(), + RestrictedExpression::new_set(team.admins.into_iter().map(|u| u.into())), + ), + ("owner".to_owned(), team.owner.into()), + ]), team.parents.into_iter().map(|euid| euid.into()).collect(), ) + .unwrap() } } @@ -170,9 +203,9 @@ impl List { #[cfg(not(feature = "use-templates"))] { let readers_uid = store.fresh_euid::(TYPE_TEAM.clone()).unwrap(); - let readers = Team::new(readers_uid.clone()); + let readers = Team::new_wo_name(readers_uid.clone(), owner.clone()); let writers_uid = store.fresh_euid::(TYPE_TEAM.clone()).unwrap(); - let writers = Team::new(writers_uid.clone()); + let writers = Team::new_wo_name(writers_uid.clone(), owner.clone()); store.insert_team(readers); store.insert_team(writers); Self { diff --git a/tinytodo/src/util.rs b/tinytodo/src/util.rs index 51a10b4..f8c30be 100644 --- a/tinytodo/src/util.rs +++ b/tinytodo/src/util.rs @@ -255,6 +255,12 @@ impl From for EntityUid { } } +impl From for RestrictedExpression { + fn from(value: UserUid) -> Self { + RestrictedExpression::new_entity_uid(value.as_ref().clone().into()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(try_from = "EntityUid")] #[serde(into = "EntityUid")] @@ -268,6 +274,12 @@ impl TryFrom for TeamUid { } } +impl From for RestrictedExpression { + fn from(value: TeamUid) -> Self { + RestrictedExpression::new_entity_uid(value.as_ref().clone().into()) + } +} + impl AsRef for TeamUid { fn as_ref(&self) -> &EntityUid { &self.0 diff --git a/tinytodo/tinytodo-templates.cedarschema b/tinytodo/tinytodo-templates.cedarschema index 8f120ae..57f23eb 100644 --- a/tinytodo/tinytodo-templates.cedarschema +++ b/tinytodo/tinytodo-templates.cedarschema @@ -5,7 +5,11 @@ type Task = { }; type Tasks = Set; -entity Team in [Team, Application]; +entity Team in [Team, Application] { + "owner": User, + "admins": Set, +}; + entity List in [Application] = { "name": String, "owner": User, diff --git a/tinytodo/tinytodo.cedarschema b/tinytodo/tinytodo.cedarschema index afd6a0d..d5110c9 100644 --- a/tinytodo/tinytodo.cedarschema +++ b/tinytodo/tinytodo.cedarschema @@ -17,13 +17,16 @@ entity User in [Team, Application] = { "joblevel": Long, "location": String, }; -entity Team in [Team, Application]; +entity Team in [Team, Application] = { + "owner": User, + "admins": Set, +}; action DeleteList, GetList, UpdateList appliesTo { principal: [User], resource: [List] }; -action CreateList, GetLists appliesTo { +action CreateList, GetLists, CreateTeam appliesTo { principal: [User], resource: [Application] }; @@ -35,3 +38,13 @@ action EditShare appliesTo { principal: [User], resource: [List] }; + +type Candidate = { + "candidate": User, +}; +action AddAdmin, RemoveAdmin, AddMember, RemoveMember appliesTo { + principal: User, + resource: Team, + context: Candidate, +}; + diff --git a/tinytodo/tinytodo.py b/tinytodo/tinytodo.py index 6002589..3465892 100644 --- a/tinytodo/tinytodo.py +++ b/tinytodo/tinytodo.py @@ -239,6 +239,67 @@ def inner(list_of_lists): return inner +@web_req("Create User") +def create_user(_, name, job_level, location): + data = { + 'id': name, + 'joblevel': job_level, + 'location': location, + } + f = lambda _: 'Created User %s' % User(name) + return server.post('/api/user/create', data), f + +@web_req("Get User") +def get_user(_, name): + f = lambda u: str(u) + return server.get('/api/user/get?id=%s' % name), f + +@web_req("Create User") +def create_team(_, name, owner): + data = { + 'id': name, + 'owner': owner, + } + f = lambda _: 'Created Team %s' % Team(name) + return server.post('/api/team/create', data), f + +@web_req("Get Team") +def get_team(_, name): + f = lambda u: str(u) + return server.get('/api/team/get?id=%s' % name), f + +@web_req("Add Team Admin") +def add_admin(_, team, user, admin): + data = { + 'team': team, + 'user': user, + 'candidate': admin, + } + f = lambda _: 'Add admin {0} to team {1}'.format(User(admin), Team(team)) + return server.post('/api/team/admin/add', data), f + +@web_req("Add Team Member") +def add_member(_, team, user, admin): + data = { + 'team': team, + 'user': user, + 'candidate': admin, + } + f = lambda _: 'Add member {0} to team {1}'.format(User(admin), Team(team)) + return server.post('/api/team/member/add', data), f + +@web_req("Get Admin Teams") +def get_admin_teams(_, name): + user = User(name) + req = server.get('/api/team/manage/admin/get?uid=%s' % user.euid()) + return req, lambda v: str(v) + +@web_req("Get Member Teams") +def get_member_teams(_, name): + user = User(name) + req = server.get('/api/team/manage/member/get?uid=%s' % user.euid()) + return req, lambda v: str(v) + @web_req("Create List") def create_list(user, name): data = {