Skip to content

Commit

Permalink
Merge pull request canonical#14195 from canonical/WD-14039-restrict-d…
Browse files Browse the repository at this point in the history
…ashboard-permissions

feat: restrict cred dashboard permissions
  • Loading branch information
usamabinnadeem-10 authored Aug 20, 2024
2 parents d1f7393 + 6c62a0e commit 534888c
Show file tree
Hide file tree
Showing 10 changed files with 173 additions and 61 deletions.
21 changes: 21 additions & 0 deletions static/js/src/advantage/credentials/dashboard/api/queryFns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,24 @@ export async function issueCredlyBadge(badgeData: any) {
throw new Error(error as string);
}
}

export async function getUserPermissions() {
try {
const URL = `/credentials/api/user-permissions`;
const response = await fetch(URL, {
method: "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
});

const data = await response.json();
if (!response.ok) {
throw new Error(data.error);
}
return data;
} catch (error) {
throw new Error(error as string);
}
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const getBulkBadgesCredly = () => ["credlyIssuedBadgesBulk"];
export const postIssueCredlyBadge = () => ["credlyIssueBadge"];
export const getUserPermissionsKey = () => ["userPermissions"];
59 changes: 6 additions & 53 deletions static/js/src/advantage/credentials/dashboard/app.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,10 @@
import { createRoot } from "react-dom/client";
import * as Sentry from "@sentry/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import Exams from "./routes/Exams";
import Credly from "./routes/Credly";
import Keys from "./routes/Keys";

import UpcomingExams from "./components/UpcomingExams/UpcomingExams";
import ExamResults from "./components/ExamResults/ExamResults";
import KeysList from "./components/KeysList/KeysList";
import TestTakers from "./components/TestTakers/TestTakers";
import CertificationIssued from "./components/CertificationsIssued/CertificationIssued";
import BadgeTracking from "./components/BadgeTracking/BadgeTracking";
import QueryClient from "./components/QueryClient/QueryClient";
import Routes from "./components/Routes/Routes";
import Sidebar from "./components/Sidebar/Sidebar";
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
} from "react-router-dom";

const oneHour = 1000 * 60 * 60;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: oneHour,
retryOnMount: false,
},
},
});
import { BrowserRouter as Router } from "react-router-dom";

Sentry.init({
dsn: "https://[email protected]//13",
Expand All @@ -43,37 +16,17 @@ function App() {
return (
<Router basename="/credentials/dashboard">
<Sentry.ErrorBoundary>
<QueryClientProvider client={queryClient}>
<QueryClient>
<ReactQueryDevtools initialIsOpen={false} />
<div className="l-application">
<Sidebar />
<main className="l-main">
<section style={{ padding: "2rem" }}>
<Routes>
<Route path="/" element={<Navigate to="/exams/upcoming" />} />
<Route path="/exams" element={<Exams />}>
<Route path="/exams/upcoming" element={<UpcomingExams />} />
<Route path="/exams/results" element={<ExamResults />} />
</Route>
<Route path="/keys" element={<Keys />}>
<Route path="/keys/list" element={<KeysList />} />
</Route>
<Route path="/credly" element={<Credly />}>
<Route
path="/credly/issued"
element={<CertificationIssued />}
/>
<Route
path="/credly/badge-tracking"
element={<BadgeTracking />}
/>
</Route>
<Route path="/test-taker-stats" element={<TestTakers />} />
</Routes>
<Routes />
</section>
</main>
</div>
</QueryClientProvider>
</QueryClient>
</Sentry.ErrorBoundary>
</Router>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
QueryClient as Client,
QueryClientProvider,
} from "@tanstack/react-query";

const oneHour = 1000 * 60 * 60;
const queryClient = new Client({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
staleTime: oneHour,
retryOnMount: false,
},
},
});

type Props = {
children: React.ReactNode;
};

const QueryClient = (props: Props) => {
return (
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
);
};

export default QueryClient;
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Routes as RouterRoutes, Route, Navigate } from "react-router-dom";
import UpcomingExams from "../UpcomingExams/UpcomingExams";
import ExamResults from "../ExamResults/ExamResults";
import KeysList from "../KeysList/KeysList";
import TestTakers from "../TestTakers/TestTakers";
import CertificationIssued from "../CertificationsIssued/CertificationIssued";
import BadgeTracking from "../BadgeTracking/BadgeTracking";
import Exams from "../../routes/Exams";
import Credly from "../../routes/Credly";
import Keys from "../../routes/Keys";
import { getUserPermissions } from "../../api/queryFns";
import { getUserPermissionsKey } from "../../api/queryKeys";
import { useQuery } from "@tanstack/react-query";

const Routes = () => {
const { data: permissions } = useQuery({
queryKey: getUserPermissionsKey(),
queryFn: getUserPermissions,
});

return (
<RouterRoutes>
<Route path="/" element={<Navigate to="/exams/upcoming" />} />
<Route path="/exams" element={<Exams />}>
<Route path="/exams/upcoming" element={<UpcomingExams />} />
{permissions?.is_credentials_admin && (
<Route path="/exams/results" element={<ExamResults />} />
)}
</Route>
<Route path="/keys" element={<Keys />}>
<Route path="/keys/list" element={<KeysList />} />
</Route>
<Route path="/credly" element={<Credly />}>
<Route path="/credly/issued" element={<CertificationIssued />} />
<Route path="/credly/badge-tracking" element={<BadgeTracking />} />
</Route>
<Route path="/test-taker-stats" element={<TestTakers />} />
</RouterRoutes>
);
};

export default Routes;
23 changes: 18 additions & 5 deletions static/js/src/advantage/credentials/dashboard/routes/Exams.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
import { useMemo } from "react";
import { Tabs } from "@canonical/react-components";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getUserPermissions } from "../api/queryFns";
import { getUserPermissionsKey } from "../api/queryKeys";

const Exams = () => {
const location = useLocation();
const navigate = useNavigate();
const { data: permissions } = useQuery({
queryKey: getUserPermissionsKey(),
queryFn: getUserPermissions,
});
const tabs = useMemo(() => {
return [
const is_credentials_admin = permissions?.is_credentials_admin;
const currTabs = [
{
active: location.pathname === "/exams/upcoming",
label: "Upcoming Exams",
onClick: () => {
navigate("/exams/upcoming");
},
},
{
];

if (is_credentials_admin) {
currTabs.push({
active: location.pathname === "/exams/results",
label: "Exam Results",
onClick: () => {
navigate("/exams/results");
},
},
];
}, [location.pathname]);
});
}
return currTabs;
}, [location.pathname, permissions]);

return (
<>
<Tabs links={tabs} />
Expand Down
7 changes: 6 additions & 1 deletion webapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
get_issued_badges_bulk,
get_test_taker_stats,
issue_credly_badge,
get_cred_user_permissions,
get_my_issued_badges,
get_webhook_response,
issue_badges,
Expand Down Expand Up @@ -968,7 +969,6 @@ def takeovers_index():
"/credentials/dashboard",
view_func=cred_dashboard,
methods=["GET"],
defaults={"path": ""},
)
app.add_url_rule(
"/credentials/dashboard/<path:path>",
Expand Down Expand Up @@ -1011,6 +1011,11 @@ def takeovers_index():
view_func=issue_credly_badge,
methods=["POST"],
)
app.add_url_rule(
"/credentials/api/user-permissions",
view_func=get_cred_user_permissions,
methods=["GET"],
)

app.add_url_rule(
"/credentials/your-badges",
Expand Down
14 changes: 13 additions & 1 deletion webapp/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

COMMUNITY_TEAM = "ubuntumembers"
CREDENTIALS_TEAM = "canonical-credentials"
CREDENTIALS_SUPPORT = "canonical-credentials-support"

login_url = os.getenv("CANONICAL_LOGIN_URL", "https://login.ubuntu.com")

Expand Down Expand Up @@ -43,6 +44,9 @@ def user_info(user_session):
"is_credentials_admin": (
user_session["openid"].get("is_credentials_admin", False)
),
"is_credentials_support": (
user_session["openid"].get("is_credentials_support", False)
),
}
else:
return None
Expand Down Expand Up @@ -90,7 +94,11 @@ def login_handler():
extensions=[
openid_macaroon,
TeamsRequest(
query_membership=[COMMUNITY_TEAM, CREDENTIALS_TEAM],
query_membership=[
COMMUNITY_TEAM,
CREDENTIALS_TEAM,
CREDENTIALS_SUPPORT,
],
lp_ns_uri="http://ns.launchpad.net/2007/openid-teams",
),
],
Expand Down Expand Up @@ -121,6 +129,9 @@ def after_login(resp):

is_community_member = COMMUNITY_TEAM in resp.extensions["lp"].is_member
is_credentials_admin = CREDENTIALS_TEAM in resp.extensions["lp"].is_member
is_credentials_support = (
CREDENTIALS_SUPPORT in resp.extensions["lp"].is_member
)

flask.session["openid"] = {
"identity_url": resp.identity_url,
Expand All @@ -130,6 +141,7 @@ def after_login(resp):
"email": resp.email,
"is_community_member": is_community_member,
"is_credentials_admin": is_credentials_admin,
"is_credentials_support": is_credentials_support,
}

return flask.redirect(open_id.get_next_url())
Expand Down
16 changes: 15 additions & 1 deletion webapp/shop/cred/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
)
from webapp.shop.decorators import (
credentials_group,
credentials_admin,
shop_decorator,
canonical_staff,
get_trueability_api_instance,
Expand Down Expand Up @@ -1211,6 +1212,19 @@ def issue_credly_badge(credly_api, **kwargs):
return flask.jsonify({"error": error}), 400


@shop_decorator(area="cred", permission="user", response="json")
def get_cred_user_permissions(credly_api, **kwargs):
sso_user = user_info(flask.session)
is_credentials_admin = sso_user.get("is_credentials_admin", False)
is_credentials_support = sso_user.get("is_credentials_support", False)
return flask.jsonify(
{
"is_credentials_admin": is_credentials_admin,
"is_credentials_support": is_credentials_support,
}
)


@shop_decorator(area="cred", permission="user", response="html")
def get_my_issued_badges(credly_api, **kwargs):
sso_user_email = user_info(flask.session)["email"]
Expand Down Expand Up @@ -1320,7 +1334,7 @@ def cred_dashboard_upcoming_exams(trueability_api, **_):


@shop_decorator(area="cred", permission="user", response="json")
@credentials_group()
@credentials_admin()
def cred_dashboard_exam_results(trueability_api, **_):
try:
per_page = 50
Expand Down
20 changes: 20 additions & 0 deletions webapp/shop/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,26 @@ def decorated_function(*args, **kwargs):


def credentials_group():
def decorator(func):
@wraps(func)
def decorated_function(*args, **kwargs):
sso_user = user_info(flask.session)
if sso_user and (
(sso_user.get("is_credentials_admin", False) is True)
or (sso_user.get("is_credentials_support", False) is True)
):
return func(*args, **kwargs)

return flask.render_template(
"account/forbidden.html", reason="is_not_admin"
)

return decorated_function

return decorator


def credentials_admin():
def decorator(func):
@wraps(func)
def decorated_function(*args, **kwargs):
Expand Down

0 comments on commit 534888c

Please sign in to comment.