Skip to content

Commit

Permalink
Add support for application admins (#77)
Browse files Browse the repository at this point in the history
  • Loading branch information
kodless committed Dec 30, 2023
1 parent c2323b3 commit ab1595f
Show file tree
Hide file tree
Showing 11 changed files with 414 additions and 10 deletions.
7 changes: 7 additions & 0 deletions app/leek/api/db/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,18 @@ class FanoutTrigger:
runtime_upper_bound: float = 0


@dataclass()
class ApplicationAdmin:
email: str
since: str


@dataclass()
class Application:
app_name: str
app_key: str
app_description: str
created_at: str
owner: str
admins: Optional[List[ApplicationAdmin]]
fo_triggers: List[FanoutTrigger] = field(default_factory=lambda: [])
52 changes: 52 additions & 0 deletions app/leek/api/db/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,55 @@ def get_application_cleanup_tasks(index_alias):
return responses.search_backend_unavailable
except es_exceptions.RequestError:
return responses.application_already_exist


def uniq_admins(list_dicts):
uniq_list_of_dicts = {}
for item in list_dicts:
if item["email"] in uniq_list_of_dicts.keys():
continue
else:
uniq_list_of_dicts.update({item["email"]: item})
return list(uniq_list_of_dicts.values())


def grant_application_admin(index_alias, admin_email):
"""
Grant a user application admin role
:param admin_email: the email address of the new admin
:param index_alias: index_alias: application indices prefix AKA Application name
:return: new application definition
"""
try:
template = get_template(index_alias)
app = template["template"]["mappings"]["_meta"]
admins = app.get("admins", [])
admins.append({"email": admin_email, "since": int(time.time())*1000})
template["template"]["mappings"]["_meta"]["admins"] = uniq_admins(admins)
es.connection.indices.put_index_template(name=index_alias, body=template)
return app, 200
except es_exceptions.ConnectionError:
return responses.search_backend_unavailable
except es_exceptions.NotFoundError:
return responses.application_not_found


def revoke_application_admin(index_alias, admin_email):
"""
Revoke a user application admin role
:param admin_email: the email address of the existing admin
:param index_alias: index_alias: application indices prefix AKA Application name
:return: new application definition
"""
try:
template = get_template(index_alias)
app = template["template"]["mappings"]["_meta"]
admins = app.get("admins", [])
admins = filter(lambda admin: admin["email"] != admin_email, admins)
template["template"]["mappings"]["_meta"]["admins"] = list(admins)
es.connection.indices.put_index_template(name=index_alias, body=template)
return app, 200
except es_exceptions.ConnectionError:
return responses.search_backend_unavailable
except es_exceptions.NotFoundError:
return responses.application_not_found
38 changes: 32 additions & 6 deletions app/leek/api/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,47 @@ def build_context():
g.email = g.claims["email"]


def auth(_route=None, allowed_org_names: List = None, only_app_owner=False):
def auth(
_route=None,
allowed_org_names: List = None,
only_app_owner=False,
only_app_admins=False
):
def decorator(route):
@wraps(route)
def wrapper(*args, **kwargs):
g.app_name = request.headers.get("x-leek-app-name")
g.app_env = request.headers.get("x-leek-app-env")
if settings.LEEK_API_ENABLE_AUTH:
get_claims()
# General org authorization (all endpoints)
if len(settings.LEEK_API_WHITELISTED_ORGS) and g.org_name not in settings.LEEK_API_WHITELISTED_ORGS:
raise JWTError(f'Only {settings.LEEK_API_WHITELISTED_ORGS} are whitelisted to use this app')
# Scoped org authorization (specific endpoints)
if allowed_org_names:
if g.org_name not in allowed_org_names:
raise JWTError(f'Only {allowed_org_names} org can access this endpoint')
if only_app_owner:
# Scoped user authorization (specific endpoints)
if only_app_admins or only_app_owner:
# Check if app name is set (required to get leek app)
if not g.app_name:
return responses.missing_headers
app = get_app(f"{g.org_name}-{g.app_name}")
# Authorize
try:
app = get_app(f"{g.org_name}-{g.app_name}")
if g.email != app.get("owner"):
return responses.insufficient_permission
# only_app_admins supersedes only_app_owner because app owner is already considered an admin
if only_app_admins:
if g.email == app.get("owner"):
# Already owner no need for further check
pass
else:
admins = app.get("admins", [])
found_admins = list(filter(lambda admin: admin["email"] == g.email, admins))
if not len(found_admins):
return responses.insufficient_permission
elif only_app_owner:
if g.email != app.get("owner"):
return responses.insufficient_permission
except es_exceptions.NotFoundError:
return responses.application_not_found
except es_exceptions.ConnectionError:
Expand Down Expand Up @@ -96,8 +117,13 @@ def wrapper(*args, **kwargs):
# Get/Build application
app = get_app(f"{org_name}-{app_name}")
fo_triggers = app.pop("fo_triggers")
admins = app.pop("admins") if app.get("admins") else []
triggers = [FanoutTrigger(**t) for t in fo_triggers]
application = Application(**app, fo_triggers=triggers)
application = Application(
**app,
fo_triggers=triggers,
admins=admins
)
# Authenticate
if app_key not in [application.app_key, settings.LEEK_AGENT_API_SECRET]:
return responses.wrong_application_app_key
Expand Down
15 changes: 15 additions & 0 deletions app/leek/api/errors/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,21 @@
}
}, 400

leek_auth_disabled = {
"error": {
"code": "400007",
"message": "Leek auth is disabled",
"reason": "Please enable first using LEEK_API_ENABLE_AUTH"
}
}, 400
ineligible_admin = {
"error": {
"code": "400008",
"message": "Ineligible admin",
"reason": "User email is not part of LEEK_API_WHITELISTED_ORGS"
}
}, 400

wrong_access_refused = {
"error": {
"code": "401004",
Expand Down
5 changes: 4 additions & 1 deletion app/leek/api/routes/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ def get(self):
"""
return self.get_agent_info()

@auth(allowed_org_names=[settings.LEEK_API_OWNER_ORG])
@auth(
allowed_org_names=[settings.LEEK_API_OWNER_ORG],
only_app_admins=True
)
def post(self):
"""
Start/Restart agent
Expand Down
41 changes: 39 additions & 2 deletions app/leek/api/routes/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
from schema import SchemaError

from leek.api.decorators import auth
from leek.api.errors import responses
from leek.api.utils import generate_app_key, init_trigger
from leek.api.schemas.application import ApplicationSchema, TriggerSchema
from leek.api.db import template as apps
from leek.api.conf import settings
from leek.api.routes.api_v1 import api_v1

applications_bp = Blueprint('applications', __name__, url_prefix='/v1/applications')
Expand Down Expand Up @@ -65,7 +67,7 @@ def delete(self):
@applications_ns.route('/purge')
class PurgeApplication(Resource):

@auth(only_app_owner=True)
@auth(only_app_admins=True)
def delete(self):
"""
Purge application
Expand All @@ -77,7 +79,7 @@ def delete(self):
@applications_ns.route('/clean')
class CleanApplication(Resource):

@auth(only_app_owner=True)
@auth(only_app_admins=True)
def delete(self):
"""
Clean application
Expand Down Expand Up @@ -162,3 +164,38 @@ def delete(self, trigger_id):
index_alias=g.index_alias,
trigger_id=trigger_id
)


@applications_ns.route('/admins/<string:admin_email>')
class UpdateApplicationAdmins(Resource):

@staticmethod
def illegible_user(email):
return len(settings.LEEK_API_WHITELISTED_ORGS) and (
email.split("@")[1] in settings.LEEK_API_WHITELISTED_ORGS
or email.split("@")[0] in settings.LEEK_API_WHITELISTED_ORGS
)

@auth(only_app_owner=True)
def post(self, admin_email):
"""
Add application admin
"""
if not settings.LEEK_API_ENABLE_AUTH:
return responses.leek_auth_disabled
if not self.illegible_user(admin_email):
return responses.ineligible_admin
return apps.grant_application_admin(
index_alias=g.index_alias,
admin_email=admin_email
)

@auth(only_app_owner=True)
def delete(self, admin_email):
"""
Remove application admin
"""
return apps.revoke_application_admin(
index_alias=g.index_alias,
admin_email=admin_email
)
2 changes: 1 addition & 1 deletion app/leek/api/routes/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def get(self):
@broker_ns.route('/queue/<string:queue_name>/purge')
class PurgeQueue(Resource):

@auth(only_app_owner=True)
@auth(only_app_admins=True)
def delete(self, queue_name):
"""
Purge a specific queue
Expand Down
25 changes: 25 additions & 0 deletions app/web/src/api/application.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export interface Application {
): any;

deleteFanoutTrigger(app_name: string, trigger_id: string): any;


grantApplicationAdmin(app_name: string, admin_email: string): any;

revokeApplicationAdmin(app_name: string, admin_email: string): any;
}

export class ApplicationService implements Application {
Expand Down Expand Up @@ -131,4 +136,24 @@ export class ApplicationService implements Application {
},
});
}

grantApplicationAdmin(app_name, admin_email) {
return request({
method: "POST",
path: `/v1/applications/admins/${admin_email}`,
headers: {
"x-leek-app-name": app_name,
},
});
}

revokeApplicationAdmin(app_name, admin_email) {
return request({
method: "DELETE",
path: `/v1/applications/admins/${admin_email}`,
headers: {
"x-leek-app-name": app_name,
},
});
}
}
56 changes: 56 additions & 0 deletions app/web/src/components/data/AdminData.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from "react";
import TimeAgo from "react-timeago";

import { Typography, Space, Button } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import moment from "moment";

const Text = Typography.Text;

function AdminData(props) {
return [
{
title: "Email",
dataIndex: "email",
key: "email",
render: (email) => {
return <Text strong style={{ color: "gold" }}>
{email}
</Text>;
},
},
{
title: "Since",
dataIndex: "since",
key: "since",
render: (since) => {
return <Text style={{ color: "rgba(45,137,183,0.8)" }} strong>
{since ? moment(since).format("MMM D HH:mm:ss Z") : "-"} -{" "}
<Text>{since ? <TimeAgo date={since} /> : "-"}</Text>
</Text>
},
},
{
title: "Actions",
dataIndex: "email",
key: "email",
render: (email) => {
return (
<Space direction="horizontal" style={{ float: "right" }}>
<Button
onClick={() => props.handleDeleteAdmin(email)}
size="small"
type="primary"
ghost
danger
loading={props.adminsModifying}
icon={<DeleteOutlined />}
/>
</Space>
);
},
},
];
}

export default AdminData;

0 comments on commit ab1595f

Please sign in to comment.