Implement basic access control. (#183)

* Implement basic access control.

* Fix formatting
This commit is contained in:
Stan Silvert 2020-10-21 07:31:41 -04:00 committed by GitHub
parent 34723a0ebf
commit 49284a0f11
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 176 additions and 30 deletions

View file

@ -11,20 +11,36 @@ import { WhoAmIContextProvider } from "./context/whoami/WhoAmI";
import { ServerInfoProvider } from "./context/server-info/ServerInfoProvider"; import { ServerInfoProvider } from "./context/server-info/ServerInfoProvider";
import { AlertProvider } from "./components/alert/Alerts"; import { AlertProvider } from "./components/alert/Alerts";
import { routes } from "./route-config"; import { AccessContextProvider, useAccess } from "./context/access/Access";
import { routes, RouteDef } from "./route-config";
import { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs"; import { PageBreadCrumbs } from "./components/bread-crumb/PageBreadCrumbs";
import { ForbiddenSection } from "./ForbiddenSection";
const AppContexts = ({ children }: { children: ReactNode }) => ( const AppContexts = ({ children }: { children: ReactNode }) => (
<WhoAmIContextProvider> <WhoAmIContextProvider>
<RealmContextProvider> <RealmContextProvider>
<AccessContextProvider>
<Help> <Help>
<AlertProvider> <AlertProvider>
<ServerInfoProvider>{children}</ServerInfoProvider> <ServerInfoProvider>{children}</ServerInfoProvider>
</AlertProvider> </AlertProvider>
</Help> </Help>
</AccessContextProvider>
</RealmContextProvider> </RealmContextProvider>
</WhoAmIContextProvider> </WhoAmIContextProvider>
); );
// If someone tries to go directly to a route they don't
// have access to, show forbidden page.
type SecuredRouteProps = { route: RouteDef };
const SecuredRoute = ({ route }: SecuredRouteProps) => {
const { hasAccess } = useAccess();
if (hasAccess(route.access)) return <route.component />;
return <ForbiddenSection />;
};
export const App = () => { export const App = () => {
return ( return (
<AppContexts> <AppContexts>
@ -37,7 +53,11 @@ export const App = () => {
> >
<Switch> <Switch>
{routes(() => {}).map((route, i) => ( {routes(() => {}).map((route, i) => (
<Route key={i} {...route} exact /> <Route
key={i}
path={route.path}
component={() => <SecuredRoute route={route} />}
/>
))} ))}
</Switch> </Switch>
</Page> </Page>

5
src/ForbiddenSection.tsx Normal file
View file

@ -0,0 +1,5 @@
import React from "react";
export const ForbiddenSection = () => {
return <>Forbidden</>;
};

View file

@ -11,10 +11,13 @@ import {
import { RealmSelector } from "./components/realm-selector/RealmSelector"; import { RealmSelector } from "./components/realm-selector/RealmSelector";
import { DataLoader } from "./components/data-loader/DataLoader"; import { DataLoader } from "./components/data-loader/DataLoader";
import { HttpClientContext } from "./context/http-service/HttpClientContext"; import { HttpClientContext } from "./context/http-service/HttpClientContext";
import { useAccess } from "./context/access/Access";
import { RealmRepresentation } from "./realm/models/Realm"; import { RealmRepresentation } from "./realm/models/Realm";
import { routes } from "./route-config";
export const PageNav: React.FunctionComponent = () => { export const PageNav: React.FunctionComponent = () => {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
const { hasAccess, hasSomeAccess } = useAccess();
const httpClient = useContext(HttpClientContext)!; const httpClient = useContext(HttpClientContext)!;
const realmLoader = async () => { const realmLoader = async () => {
const response = await httpClient.doGet<RealmRepresentation[]>( const response = await httpClient.doGet<RealmRepresentation[]>(
@ -43,18 +46,36 @@ export const PageNav: React.FunctionComponent = () => {
item.event.preventDefault(); item.event.preventDefault();
}; };
const makeNavItem = (title: string, path: string) => { type LeftNavProps = { title: string; path: string };
const LeftNav = ({ title, path }: LeftNavProps) => {
const route = routes(() => {}).find((route) => route.path === path);
console.log(`hasAccess(${route!.access})=` + hasAccess(route!.access));
if (!route || !hasAccess(route.access)) return <></>;
return ( return (
<NavItem <NavItem
id={"nav-item-" + path} id={"nav-item" + path.replace("/", "-")}
to={"/" + path} to={path}
isActive={activeItem === "/" + path} isActive={activeItem === path}
> >
{t(title)} {t(title)}
</NavItem> </NavItem>
); );
}; };
const showManage = hasSomeAccess(
"view-realm",
"query-groups",
"query-users",
"view-events"
);
const showConfigure = hasSomeAccess(
"view-realm",
"query-clients",
"view-identity-providers"
);
return ( return (
<DataLoader loader={realmLoader}> <DataLoader loader={realmLoader}>
{(realmList) => ( {(realmList) => (
@ -66,22 +87,29 @@ export const PageNav: React.FunctionComponent = () => {
<RealmSelector realmList={realmList.data || []} /> <RealmSelector realmList={realmList.data || []} />
</NavItem> </NavItem>
</NavList> </NavList>
{showManage && (
<NavGroup title={t("manage")}> <NavGroup title={t("manage")}>
{makeNavItem("clients", "clients")} <LeftNav title="clients" path="/clients" />
{makeNavItem("clientScopes", "client-scopes")} <LeftNav title="clientScopes" path="/client-scopes" />
{makeNavItem("realmRoles", "roles")} <LeftNav title="realmRoles" path="/roles" />
{makeNavItem("users", "users")} <LeftNav title="users" path="/users" />
{makeNavItem("groups", "groups")} <LeftNav title="groups" path="/groups" />
{makeNavItem("sessions", "sessions")} <LeftNav title="sessions" path="/sessions" />
{makeNavItem("events", "events")} <LeftNav title="events" path="/events" />
</NavGroup> </NavGroup>
)}
{showConfigure && (
<NavGroup title={t("configure")}> <NavGroup title={t("configure")}>
{makeNavItem("realmSettings", "realm-settings")} <LeftNav title="realmSettings" path="/realm-settings" />
{makeNavItem("authentication", "authentication")} <LeftNav title="authentication" path="/authentication" />
{makeNavItem("identityProviders", "identity-providers")} <LeftNav
{makeNavItem("userFederation", "user-federation")} title="identityProviders"
path="/identity-providers"
/>
<LeftNav title="userFederation" path="/user-federation" />
</NavGroup> </NavGroup>
)}
</Nav> </Nav>
} }
/> />

View file

@ -0,0 +1,39 @@
import React, { createContext, useContext } from "react";
import { RealmContext } from "../../context/realm-context/RealmContext";
import { WhoAmIContext } from "../../context/whoami/WhoAmI";
import { AccessType } from "../../context/whoami/who-am-i-model";
type AccessContextProps = {
hasAccess: (...types: AccessType[]) => boolean;
hasSomeAccess: (...types: AccessType[]) => boolean;
};
export const AccessContext = createContext<AccessContextProps>({
hasAccess: () => false,
hasSomeAccess: () => false,
});
export const useAccess = () => useContext(AccessContext);
type AccessProviderProps = { children: React.ReactNode };
export const AccessContextProvider = ({ children }: AccessProviderProps) => {
const whoami = useContext(WhoAmIContext);
const realmCtx = useContext(RealmContext);
const access = () => whoami.getRealmAccess()[realmCtx.realm];
const hasAccess = (...types: AccessType[]) => {
return types.every((type) => type === "anyone" || access().includes(type));
};
const hasSomeAccess = (...types: AccessType[]) => {
return types.some((type) => type === "anyone" || access().includes(type));
};
return (
<AccessContext.Provider value={{ hasAccess, hasSomeAccess }}>
{children}
</AccessContext.Provider>
);
};

View file

@ -1,7 +1,7 @@
import React, { useContext } from "react"; import React, { useContext } from "react";
import i18n from "../../i18n"; import i18n from "../../i18n";
import WhoAmIRepresentation from "./who-am-i-model"; import WhoAmIRepresentation, { AccessType } from "./who-am-i-model";
import { HttpClientContext } from "../http-service/HttpClientContext"; import { HttpClientContext } from "../http-service/HttpClientContext";
import { KeycloakContext } from "../auth/KeycloakContext"; import { KeycloakContext } from "../auth/KeycloakContext";
@ -40,7 +40,9 @@ export class WhoAmI {
return this.me !== undefined && this.me.createRealm; return this.me !== undefined && this.me.createRealm;
} }
public getRealmAccess(): Readonly<{ [key: string]: ReadonlyArray<string> }> { public getRealmAccess(): Readonly<{
[key: string]: ReadonlyArray<AccessType>;
}> {
if (this.me === undefined) return {}; if (this.me === undefined) return {};
return this.me.realm_access; return this.me.realm_access;

View file

@ -1,8 +1,29 @@
export type AccessType =
| "view-realm"
| "view-identity-providers"
| "manage-identity-providers"
| "impersonation"
| "create-client"
| "manage-users"
| "query-realms"
| "view-authorization"
| "query-clients"
| "query-users"
| "manage-events"
| "manage-realm"
| "view-events"
| "view-users"
| "view-clients"
| "manage-authorization"
| "manage-clients"
| "query-groups"
| "anyone";
export default interface WhoAmIRepresentation { export default interface WhoAmIRepresentation {
userId: string; userId: string;
realm: string; realm: string;
displayName: string; displayName: string;
locale: string; locale: string;
createRealm: boolean; createRealm: boolean;
realm_access: { [key: string]: string[] }; realm_access: { [key: string]: AccessType[] };
} }

View file

@ -18,105 +18,136 @@ import { SessionsSection } from "./sessions/SessionsSection";
import { UserFederationSection } from "./user-federation/UserFederationSection"; import { UserFederationSection } from "./user-federation/UserFederationSection";
import { UsersSection } from "./user/UsersSection"; import { UsersSection } from "./user/UsersSection";
export const routes = (t: TFunction) => [ import { AccessType } from "./context/whoami/who-am-i-model";
export type RouteDef = {
path: string;
component: () => JSX.Element;
breadcrumb: TFunction | "";
access: AccessType;
};
type RoutesFn = (t: TFunction) => RouteDef[];
export const routes: RoutesFn = (t: TFunction) => [
{ {
path: "/add-realm", path: "/add-realm",
component: NewRealmForm, component: NewRealmForm,
breadcrumb: t("realm:createRealm"), breadcrumb: t("realm:createRealm"),
access: "manage-realm",
}, },
{ {
path: "/clients", path: "/clients",
component: ClientsSection, component: ClientsSection,
breadcrumb: t("clients:clientList"), breadcrumb: t("clients:clientList"),
access: "query-clients",
}, },
{ {
path: "/clients/:id", path: "/clients/:id",
component: ClientSettings, component: ClientSettings,
breadcrumb: t("clients:clientSettings"), breadcrumb: t("clients:clientSettings"),
access: "view-clients",
}, },
{ {
path: "/add-client", path: "/add-client",
component: NewClientForm, component: NewClientForm,
breadcrumb: t("clients:createClient"), breadcrumb: t("clients:createClient"),
access: "manage-clients",
}, },
{ {
path: "/import-client", path: "/import-client",
component: ImportForm, component: ImportForm,
breadcrumb: t("clients:importClient"), breadcrumb: t("clients:importClient"),
access: "manage-clients",
}, },
{ {
path: "/client-scopes", path: "/client-scopes",
component: ClientScopesSection, component: ClientScopesSection,
breadcrumb: t("client-scopes:clientScopeList"), breadcrumb: t("client-scopes:clientScopeList"),
access: "view-clients",
}, },
{ {
path: "/client-scopes/add-client-scopes", path: "/client-scopes/add-client-scopes",
component: ClientScopeForm, component: ClientScopeForm,
breadcrumb: t("client-scopes:createClientScope"), breadcrumb: t("client-scopes:createClientScope"),
access: "manage-clients",
}, },
{ {
path: "/client-scopes/:id", path: "/client-scopes/:id",
component: ClientScopeForm, component: ClientScopeForm,
breadcrumb: t("client-scopes:clientScopeDetails"), breadcrumb: t("client-scopes:clientScopeDetails"),
access: "view-clients",
}, },
{ {
path: "/roles", path: "/roles",
component: RealmRolesSection, component: RealmRolesSection,
breadcrumb: t("roles:roleList"), breadcrumb: t("roles:roleList"),
access: "view-realm",
}, },
{ {
path: "/add-role", path: "/add-role",
component: NewRoleForm, component: NewRoleForm,
breadcrumb: t("roles:createRole"), breadcrumb: t("roles:createRole"),
access: "manage-realm",
}, },
{ {
path: "/users", path: "/users",
component: UsersSection, component: UsersSection,
breadcrumb: t("users:title"), breadcrumb: t("users:title"),
access: "query-users",
}, },
{ {
path: "/groups", path: "/groups",
component: GroupsSection, component: GroupsSection,
breadcrumb: t("groups"), breadcrumb: t("groups"),
access: "query-groups",
}, },
{ {
path: "/sessions", path: "/sessions",
component: SessionsSection, component: SessionsSection,
breadcrumb: t("sessions:title"), breadcrumb: t("sessions:title"),
access: "view-realm",
}, },
{ {
path: "/events", path: "/events",
component: EventsSection, component: EventsSection,
breadcrumb: t("events:title"), breadcrumb: t("events:title"),
access: "view-events",
}, },
{ {
path: "/realm-settings", path: "/realm-settings",
component: RealmSettingsSection, component: RealmSettingsSection,
breadcrumb: t("realmSettings"), breadcrumb: t("realmSettings"),
access: "view-realm",
}, },
{ {
path: "/authentication", path: "/authentication",
component: AuthenticationSection, component: AuthenticationSection,
breadcrumb: t("authentication"), breadcrumb: t("authentication"),
access: "view-realm",
}, },
{ {
path: "/identity-providers", path: "/identity-providers",
component: IdentityProvidersSection, component: IdentityProvidersSection,
breadcrumb: t("identityProviders"), breadcrumb: t("identityProviders"),
access: "view-identity-providers",
}, },
{ {
path: "/user-federation", path: "/user-federation",
component: UserFederationSection, component: UserFederationSection,
breadcrumb: t("userFederation"), breadcrumb: t("userFederation"),
access: "view-realm",
}, },
{ {
path: "/", path: "/",
component: ClientsSection, component: ClientsSection,
breadcrumb: t("common:home"), breadcrumb: t("common:home"),
access: "anyone",
}, },
{ {
path: "", path: "",
component: PageNotFoundSection, component: PageNotFoundSection,
breadcrumb: "", breadcrumb: "",
access: "anyone",
}, },
]; ];