Gestion des autorisations côté serveur #20
|
@ -1,4 +1,4 @@
|
||||||
import React, { FunctionComponent, useState } from 'react';
|
import React, { FunctionComponent, useState, useEffect } from 'react';
|
||||||
import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
|
import { BrowserRouter, Route, Redirect, Switch } from "react-router-dom";
|
||||||
import { HomePage } from './HomePage/HomePage';
|
import { HomePage } from './HomePage/HomePage';
|
||||||
import { ProfilePage } from './ProfilePage/ProfilePage';
|
import { ProfilePage } from './ProfilePage/ProfilePage';
|
||||||
|
@ -6,44 +6,76 @@ import { WorkgroupPage } from './WorkgroupPage/WorkgroupPage';
|
||||||
import { DecisionSupportFilePage } from './DecisionSupportFilePage/DecisionSupportFilePage';
|
import { DecisionSupportFilePage } from './DecisionSupportFilePage/DecisionSupportFilePage';
|
||||||
import { DashboardPage } from './DashboardPage/DashboardPage';
|
import { DashboardPage } from './DashboardPage/DashboardPage';
|
||||||
import { useUserProfile } from '../gql/queries/profile';
|
import { useUserProfile } from '../gql/queries/profile';
|
||||||
import { LoggedInContext } from '../hooks/useLoggedIn';
|
import { LoggedInContext, getSavedLoggedIn, saveLoggedIn } from '../hooks/useLoggedIn';
|
||||||
import { PrivateRoute } from './PrivateRoute';
|
import { PrivateRoute } from './PrivateRoute';
|
||||||
import { useKonamiCode } from '../hooks/useKonamiCode';
|
import { useKonamiCode } from '../hooks/useKonamiCode';
|
||||||
import { Modal } from './Modal';
|
import { Modal } from './Modal';
|
||||||
|
import { createClient } from '../util/apollo';
|
||||||
|
import { ApolloProvider } from '@apollo/client';
|
||||||
|
import { LogoutPage } from './LogoutPage';
|
||||||
|
|
||||||
export interface AppProps {
|
export interface AppProps {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const App: FunctionComponent<AppProps> = () => {
|
export const App: FunctionComponent<AppProps> = () => {
|
||||||
const { user } = useUserProfile();
|
const [ loggedIn, setLoggedIn ] = useState(getSavedLoggedIn());
|
||||||
|
|
||||||
|
const client = createClient((loggedIn) => {
|
||||||
|
setLoggedIn(loggedIn);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveLoggedIn(loggedIn);
|
||||||
|
}, [loggedIn]);
|
||||||
|
|
||||||
const [ showBoneyM, setShowBoneyM ] = useState(false);
|
const [ showBoneyM, setShowBoneyM ] = useState(false);
|
||||||
useKonamiCode(() => setShowBoneyM(true));
|
useKonamiCode(() => setShowBoneyM(true));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoggedInContext.Provider value={user.id !== ''}>
|
<ApolloProvider client={client}>
|
||||||
<BrowserRouter>
|
<LoggedInContext.Provider value={loggedIn}>
|
||||||
<Switch>
|
<UserSessionCheck setLoggedIn={setLoggedIn} />
|
||||||
<Route path="/" exact component={HomePage} />
|
<BrowserRouter>
|
||||||
<PrivateRoute path="/profile" exact component={ProfilePage} />
|
<Switch>
|
||||||
<PrivateRoute path="/workgroups/:id" exact component={WorkgroupPage} />
|
<Route path="/" exact component={HomePage} />
|
||||||
<PrivateRoute path="/decisions/:id" exact component={DecisionSupportFilePage} />
|
<PrivateRoute path="/profile" exact component={ProfilePage} />
|
||||||
<PrivateRoute path="/dashboard" exact component={DashboardPage} />
|
<PrivateRoute path="/workgroups/:id" exact component={WorkgroupPage} />
|
||||||
<Route component={() => <Redirect to="/" />} />
|
<PrivateRoute path="/decisions/:id" exact component={DecisionSupportFilePage} />
|
||||||
</Switch>
|
<PrivateRoute path="/dashboard" exact component={DashboardPage} />
|
||||||
</BrowserRouter>
|
<PrivateRoute path="/logout" exact component={LogoutPage} />
|
||||||
{
|
<Route component={() => <Redirect to="/" />} />
|
||||||
showBoneyM ?
|
</Switch>
|
||||||
<Modal active={true} showCloseButton={true} onClose={() => setShowBoneyM(false)}>
|
</BrowserRouter>
|
||||||
<iframe width={560} height={315}
|
{
|
||||||
frameBorder={0}
|
showBoneyM ?
|
||||||
allowFullScreen={true}
|
<Modal active={true} showCloseButton={true} onClose={() => setShowBoneyM(false)}>
|
||||||
src="https://www.youtube.com/embed/uVzT5QEEQ2c?autoplay=1" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture">
|
<iframe width={560} height={315}
|
||||||
</iframe>
|
frameBorder={0}
|
||||||
</Modal> :
|
allowFullScreen={true}
|
||||||
null
|
src="https://www.youtube.com/embed/uVzT5QEEQ2c?autoplay=1" allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture">
|
||||||
}
|
</iframe>
|
||||||
</LoggedInContext.Provider>
|
</Modal> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</LoggedInContext.Provider>
|
||||||
|
</ApolloProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserSessionCheckProps {
|
||||||
|
setLoggedIn: (boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserSessionCheck: FunctionComponent<UserSessionCheckProps> = ({ setLoggedIn }) => {
|
||||||
|
const { user, loading } = useUserProfile();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoggedIn(user.id !== '');
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
|
@ -26,8 +26,7 @@ export function DecisionSupportFilePanel() {
|
||||||
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Closed
|
itemFilter: (item: Item) => (item as DecisionSupportFile).status === DecisionSupportFileStatus.Closed
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemPanel
|
<ItemPanel
|
||||||
className='is-link'
|
className='is-link'
|
||||||
|
|
|
@ -11,8 +11,6 @@ export interface TabDefinition {
|
||||||
label: string
|
label: string
|
||||||
itemFilter?: (item: Item) => boolean
|
itemFilter?: (item: Item) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface ItemPanelProps {
|
export interface ItemPanelProps {
|
||||||
className?: string
|
className?: string
|
||||||
itemIconClassName?: string
|
itemIconClassName?: string
|
||||||
|
@ -30,9 +28,12 @@ export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
title, className, newItemUrl,
|
title, className, newItemUrl,
|
||||||
itemKey, itemLabel,
|
itemKey, itemLabel,
|
||||||
itemIconClassName, itemUrl
|
itemIconClassName, itemUrl,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const items = props.items || [];
|
||||||
|
const tabs = props.tabs || [];
|
||||||
|
|
||||||
const [ state, setState ] = useState({ selectedTab: 0, filteredItems: [] });
|
const [ state, setState ] = useState({ selectedTab: 0, filteredItems: [] });
|
||||||
|
|
||||||
const filterItemsForTab = (tab: TabDefinition, items: Item[]) => {
|
const filterItemsForTab = (tab: TabDefinition, items: Item[]) => {
|
||||||
|
@ -42,7 +43,6 @@ export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
|
||||||
|
|
||||||
const selectTab = (tabIndex: number) => {
|
const selectTab = (tabIndex: number) => {
|
||||||
setState(state => {
|
setState(state => {
|
||||||
const { tabs, items } = props;
|
|
||||||
const newTab = Array.isArray(tabs) && tabs.length > 0 ? tabs[tabIndex] : null;
|
const newTab = Array.isArray(tabs) && tabs.length > 0 ? tabs[tabIndex] : null;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
@ -61,7 +61,7 @@ export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
|
||||||
filteredItems: filterItemsForTab(newTab, items),
|
filteredItems: filterItemsForTab(newTab, items),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [props.items, props.tabs]);
|
}, [items, tabs]);
|
||||||
|
|
||||||
const itemElements = state.filteredItems.map((item: Item, i: number) => {
|
const itemElements = state.filteredItems.map((item: Item, i: number) => {
|
||||||
return (
|
return (
|
||||||
|
@ -74,8 +74,6 @@ export const ItemPanel: FunctionComponent<ItemPanelProps> = (props) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabs = props.tabs || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={`panel ${className}`}>
|
<nav className={`panel ${className}`}>
|
||||||
<div className="level is-mobile panel-heading mb-0">
|
<div className="level is-mobile panel-heading mb-0">
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { } from 'react';
|
||||||
import { Workgroup, inWorkgroup } from '../../types/workgroup';
|
import { Workgroup, inWorkgroup } from '../../types/workgroup';
|
||||||
import { User } from '../../types/user';
|
import { useWorkgroups } from '../../gql/queries/workgroups';
|
||||||
import { Link } from 'react-router-dom';
|
import { useUserProfile } from '../../gql/queries/profile';
|
||||||
import { useWorkgroupsQuery, useWorkgroups } from '../../gql/queries/workgroups';
|
|
||||||
import { useUserProfileQuery, useUserProfile } from '../../gql/queries/profile';
|
|
||||||
import { WithLoader } from '../WithLoader';
|
|
||||||
import { ItemPanel, Item } from './ItemPanel';
|
import { ItemPanel, Item } from './ItemPanel';
|
||||||
|
|
||||||
export function WorkgroupsPanel() {
|
export function WorkgroupsPanel() {
|
||||||
|
|
|
@ -3,14 +3,15 @@ import { Page } from '../Page';
|
||||||
import { WelcomeContent } from './WelcomeContent';
|
import { WelcomeContent } from './WelcomeContent';
|
||||||
import { useUserProfile } from '../../gql/queries/profile';
|
import { useUserProfile } from '../../gql/queries/profile';
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
|
import { useLoggedIn } from '../../hooks/useLoggedIn';
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { user } = useUserProfile();
|
const loggedIn = useLoggedIn();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user.id !== '') history.push('/dashboard');
|
if (loggedIn) history.push('/dashboard');
|
||||||
}, [user.id])
|
}, [loggedIn])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title="Accueil">
|
<Page title="Accueil">
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React, { FunctionComponent, useEffect } from "react";
|
||||||
|
import { saveLoggedIn } from "../hooks/useLoggedIn";
|
||||||
|
import { Config } from "../config";
|
||||||
|
|
||||||
|
export interface LogoutPageProps {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogoutPage: FunctionComponent<LogoutPageProps> = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
saveLoggedIn(false);
|
||||||
|
window.location.replace(Config.logoutURL);
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
};
|
|
@ -44,11 +44,11 @@ export function Navbar() {
|
||||||
</span>
|
</span>
|
||||||
<span>Mon profil</span>
|
<span>Mon profil</span>
|
||||||
</Link>
|
</Link>
|
||||||
<a className="button is-warning" href={Config.logoutURL}>
|
<Link className="button is-warning" to="/logout">
|
||||||
<span className="icon">
|
<span className="icon">
|
||||||
<i className="fas fa-sign-out-alt"></i>
|
<i className="fas fa-sign-out-alt"></i>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</Link>
|
||||||
</Fragment> :
|
</Fragment> :
|
||||||
<a className="button is-primary" href={Config.loginURL}>
|
<a className="button is-primary" href={Config.loginURL}>
|
||||||
<span className="icon">
|
<span className="icon">
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import React, { useState, ChangeEvent, useEffect } from 'react';
|
import React, { useState, ChangeEvent, useEffect } from 'react';
|
||||||
import { Workgroup } from '../../types/workgroup';
|
import { Workgroup } from '../../types/workgroup';
|
||||||
|
import { useIsAuthorized } from '../../gql/queries/authorization';
|
||||||
|
|
||||||
export interface InfoFormProps {
|
export interface InfoFormProps {
|
||||||
workgroup: Workgroup
|
workgroup: Workgroup
|
||||||
onChange?: (workgroup: Workgroup) => void
|
onChange?: (workgroup: Workgroup) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InfoForm({ workgroup, onChange }: InfoFormProps) {
|
export function InfoForm({ workgroup, onChange }: InfoFormProps) {
|
||||||
const [ state, setState ] = useState({
|
const [ state, setState ] = useState({
|
||||||
changed: false,
|
changed: false,
|
||||||
workgroup: {
|
workgroup: {
|
||||||
|
@ -17,6 +18,15 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isAuthorized } = useIsAuthorized({
|
||||||
|
variables: {
|
||||||
|
action: 'update',
|
||||||
|
object: {
|
||||||
|
workgroupId: state.workgroup.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, state.workgroup.id === '' ? true : false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState({
|
setState({
|
||||||
changed: false,
|
changed: false,
|
||||||
|
@ -60,7 +70,8 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="label">Nom du groupe</label>
|
<label className="label">Nom du groupe</label>
|
||||||
<div className="control">
|
<div className="control">
|
||||||
<input type="text" className="input" value={state.workgroup.name}
|
<input type="text" className="input" value={state.workgroup.name}
|
||||||
|
disabled={!isAuthorized}
|
||||||
onChange={onWorkgroupAttrChange.bind(null, "name")} />
|
onChange={onWorkgroupAttrChange.bind(null, "name")} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -85,7 +96,7 @@ export function InfoForm({ workgroup, onChange }: InfoFormProps) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
<div className="buttons is-right">
|
<div className="buttons is-right">
|
||||||
<button disabled={!state.changed}
|
<button disabled={!state.changed || !isAuthorized}
|
||||||
className="button is-success" onClick={onSaveClick}>
|
className="button is-success" onClick={onSaveClick}>
|
||||||
<span>Enregistrer</span>
|
<span>Enregistrer</span>
|
||||||
<span className="icon"><i className="fa fa-save"></i></span>
|
<span className="icon"><i className="fa fa-save"></i></span>
|
||||||
|
|
|
@ -2,8 +2,8 @@ import React, { useEffect, useState, Fragment } from 'react';
|
||||||
import { Page } from '../Page';
|
import { Page } from '../Page';
|
||||||
import { WithLoader } from '../WithLoader';
|
import { WithLoader } from '../WithLoader';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { useWorkgroupsQuery } from '../../gql/queries/workgroups';
|
import { useWorkgroupsQuery, useWorkgroups } from '../../gql/queries/workgroups';
|
||||||
import { useUserProfileQuery } from '../../gql/queries/profile';
|
import { useUserProfileQuery, useUserProfile } from '../../gql/queries/profile';
|
||||||
import { MembersPanel } from './MembersPanel';
|
import { MembersPanel } from './MembersPanel';
|
||||||
import { User } from '../../types/user';
|
import { User } from '../../types/user';
|
||||||
import { InfoPanel } from './InfoPanel';
|
import { InfoPanel } from './InfoPanel';
|
||||||
|
@ -12,17 +12,19 @@ import { useJoinWorkgroupMutation, useLeaveWorkgroupMutation, useCloseWorkgroupM
|
||||||
|
|
||||||
export function WorkgroupPage() {
|
export function WorkgroupPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const workgroupsQuery = useWorkgroupsQuery({
|
const { workgroups } = useWorkgroups({
|
||||||
variables:{
|
variables:{
|
||||||
filter: {
|
filter: {
|
||||||
ids: [id],
|
ids: [id],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const userProfileQuery = useUserProfileQuery();
|
const { user } = useUserProfile();
|
||||||
const [ joinWorkgroup, joinWorkgroupMutation ] = useJoinWorkgroupMutation();
|
|
||||||
const [ leaveWorkgroup, leaveWorkgroupMutation ] = useLeaveWorkgroupMutation();
|
const [ joinWorkgroup ] = useJoinWorkgroupMutation();
|
||||||
const [ closeWorkgroup, closeWorkgroupMutation ] = useCloseWorkgroupMutation();
|
const [ leaveWorkgroup ] = useLeaveWorkgroupMutation();
|
||||||
|
const [ closeWorkgroup ] = useCloseWorkgroupMutation();
|
||||||
|
|
||||||
const [ state, setState ] = useState({
|
const [ state, setState ] = useState({
|
||||||
userProfileId: '',
|
userProfileId: '',
|
||||||
workgroup: {
|
workgroup: {
|
||||||
|
@ -35,14 +37,12 @@ export function WorkgroupPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workgroupsQuery.data) return;
|
setState(state => ({...state, workgroup:{ ...state.workgroup, ...workgroups[0]}}));
|
||||||
setState(state => ({...state, workgroup:{ ...state.workgroup, ...workgroupsQuery.data.workgroups[0]}}));
|
}, [workgroups]);
|
||||||
}, [workgroupsQuery.data]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userProfileQuery.data) return;
|
setState(state => ({...state, userProfileId: user.id }));
|
||||||
setState(state => ({...state, userProfileId: userProfileQuery.data.userProfile.id }));
|
}, [user]);
|
||||||
}, [userProfileQuery.data]);
|
|
||||||
|
|
||||||
const onJoinWorkgroupClick = () => {
|
const onJoinWorkgroupClick = () => {
|
||||||
joinWorkgroup({
|
joinWorkgroup({
|
||||||
|
@ -54,6 +54,18 @@ export function WorkgroupPage() {
|
||||||
|
|
||||||
const onLeaveWorkgroupClick = () => {
|
const onLeaveWorkgroupClick = () => {
|
||||||
leaveWorkgroup({
|
leaveWorkgroup({
|
||||||
|
update: (cache, result) => {
|
||||||
|
cache.modify({
|
||||||
|
id: cache.identify(result.data.leaveWorkgroup),
|
||||||
|
fields: {
|
||||||
|
members(existingMembers, { readField }) {
|
||||||
|
return existingMembers.filter(
|
||||||
|
user => state.userProfileId !== readField('id', user)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
variables: {
|
variables: {
|
||||||
workgroupId: state.workgroup.id,
|
workgroupId: state.workgroup.id,
|
||||||
}
|
}
|
||||||
|
@ -121,16 +133,14 @@ export function WorkgroupPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<WithLoader loading={[workgroupsQuery.loading, userProfileQuery.loading, joinWorkgroupMutation.loading, leaveWorkgroupMutation.loading]}>
|
<div className="columns">
|
||||||
<div className="columns">
|
<div className="column">
|
||||||
<div className="column">
|
<InfoPanel workgroup={state.workgroup as Workgroup} />
|
||||||
<InfoPanel workgroup={state.workgroup as Workgroup} />
|
|
||||||
</div>
|
|
||||||
<div className="column">
|
|
||||||
<MembersPanel users={state.workgroup.members as User[]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</WithLoader>
|
<div className="column">
|
||||||
|
<MembersPanel users={state.workgroup.members as User[]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
|
|
||||||
import { Config } from '../config';
|
|
||||||
import { WebSocketLink } from "@apollo/client/link/ws";
|
|
||||||
import { RetryLink } from "@apollo/client/link/retry";
|
|
||||||
import { SubscriptionClient } from "subscriptions-transport-ws";
|
|
||||||
import { User } from '../types/user';
|
|
||||||
|
|
||||||
const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, {
|
|
||||||
reconnect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const link = new RetryLink({attempts: {max: 2}}).split(
|
|
||||||
(operation) => operation.operationName === 'subscription',
|
|
||||||
new WebSocketLink(subscriptionClient),
|
|
||||||
new HttpLink({ uri: Config.graphQLEndpoint, credentials: 'include' })
|
|
||||||
);
|
|
||||||
|
|
||||||
const cache = new InMemoryCache({
|
|
||||||
typePolicies: {
|
|
||||||
Workgroup: {
|
|
||||||
fields: {
|
|
||||||
members: {
|
|
||||||
merge: mergeArrayByField<User>("id"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const client = new ApolloClient<any>({
|
|
||||||
cache: cache,
|
|
||||||
link: link,
|
|
||||||
});
|
|
||||||
|
|
||||||
function mergeArrayByField<T>(fieldName: string) {
|
|
||||||
return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => {
|
|
||||||
const merged: any[] = existing ? existing.slice(0) : [];
|
|
||||||
const objectFieldToIndex: Record<string, number> = Object.create(null);
|
|
||||||
if (existing) {
|
|
||||||
existing.forEach((obj, index) => {
|
|
||||||
objectFieldToIndex[readField(fieldName, obj)] = index;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
incoming.forEach(obj => {
|
|
||||||
const field = readField(fieldName, obj);
|
|
||||||
const index = objectFieldToIndex[field];
|
|
||||||
if (typeof index === "number") {
|
|
||||||
merged[index] = mergeObjects(merged[index], obj);
|
|
||||||
} else {
|
|
||||||
objectFieldToIndex[name] = merged.length;
|
|
||||||
merged.push(obj);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -9,7 +9,16 @@ mutation createDecisionSupportFile($changes: DecisionSupportFileChanges!) {
|
||||||
status,
|
status,
|
||||||
sections,
|
sections,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt
|
updatedAt,
|
||||||
|
workgroup {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
members {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
name
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
@ -27,7 +36,16 @@ mutation updateDecisionSupportFile($id: ID!, $changes: DecisionSupportFileChange
|
||||||
status,
|
status,
|
||||||
sections,
|
sections,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt
|
updatedAt,
|
||||||
|
workgroup {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
members {
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
name
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { gql, useQuery, useMutation } from '@apollo/client';
|
import { gql, useQuery, useMutation, FetchResult } from '@apollo/client';
|
||||||
import { QUERY_WORKGROUP } from '../queries/workgroups';
|
import { QUERY_WORKGROUP } from '../queries/workgroups';
|
||||||
|
import { QUERY_IS_AUTHORIZED } from '../queries/authorization';
|
||||||
|
|
||||||
export const MUTATION_UPDATE_WORKGROUP = gql`
|
export const MUTATION_UPDATE_WORKGROUP = gql`
|
||||||
mutation updateWorkgroup($workgroupId: ID!, $changes: WorkgroupChanges!) {
|
mutation updateWorkgroup($workgroupId: ID!, $changes: WorkgroupChanges!) {
|
||||||
|
@ -57,7 +58,19 @@ mutation joinWorkgroup($workgroupId: ID!) {
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
export function useJoinWorkgroupMutation() {
|
export function useJoinWorkgroupMutation() {
|
||||||
return useMutation(MUTATION_JOIN_WORKGROUP);
|
return useMutation(MUTATION_JOIN_WORKGROUP, {
|
||||||
|
refetchQueries: ({ data }: FetchResult) => {
|
||||||
|
return [{
|
||||||
|
query: QUERY_IS_AUTHORIZED,
|
||||||
|
variables: {
|
||||||
|
action: 'update',
|
||||||
|
object: {
|
||||||
|
workgroupId: data.joinWorkgroup.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const MUTATION_LEAVE_WORKGROUP = gql`
|
const MUTATION_LEAVE_WORKGROUP = gql`
|
||||||
|
@ -76,7 +89,27 @@ mutation leaveWorkgroup($workgroupId: ID!) {
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
export function useLeaveWorkgroupMutation() {
|
export function useLeaveWorkgroupMutation() {
|
||||||
return useMutation(MUTATION_LEAVE_WORKGROUP);
|
return useMutation(MUTATION_LEAVE_WORKGROUP, {
|
||||||
|
refetchQueries: ({ data }: FetchResult) => {
|
||||||
|
return [{
|
||||||
|
query: QUERY_WORKGROUP,
|
||||||
|
variables: {
|
||||||
|
filter: {
|
||||||
|
ids: [data.leaveWorkgroup.id],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: QUERY_IS_AUTHORIZED,
|
||||||
|
variables: {
|
||||||
|
action: 'update',
|
||||||
|
object: {
|
||||||
|
workgroupId: data.leaveWorkgroup.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const MUTATION_CLOSE_WORKGROUP = gql`
|
const MUTATION_CLOSE_WORKGROUP = gql`
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { gql, useQuery } from '@apollo/client';
|
||||||
|
import { useGraphQLData } from './helper';
|
||||||
|
|
||||||
|
export const QUERY_IS_AUTHORIZED = gql`
|
||||||
|
query isAuthorized($action: String!, $object: AuthorizationObject!) {
|
||||||
|
isAuthorized(action: $action, object: $object)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function useIsAuthorizedQuery(options = {}) {
|
||||||
|
return useQuery(QUERY_IS_AUTHORIZED, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsAuthorized(options = {}, defaultValue = false) {
|
||||||
|
const { data, loading, error } = useGraphQLData<boolean>(
|
||||||
|
QUERY_IS_AUTHORIZED, 'isAuthorized', defaultValue, options
|
||||||
|
);
|
||||||
|
return { isAuthorized: data, loading, error };
|
||||||
|
}
|
|
@ -16,7 +16,9 @@ export const QUERY_DECISION_SUPPORT_FILES = gql`
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
members {
|
members {
|
||||||
id
|
id,
|
||||||
|
email,
|
||||||
|
name
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,23 @@
|
||||||
import React, { useState, useContext } from "react";
|
import React, { useContext, useEffect } from "react";
|
||||||
|
|
||||||
export const LoggedInContext = React.createContext(false);
|
const LOGGED_IN_KEY = 'loggedIn';
|
||||||
|
|
||||||
|
export const LoggedInContext = React.createContext(getSavedLoggedIn());
|
||||||
|
|
||||||
export const useLoggedIn = () => {
|
export const useLoggedIn = () => {
|
||||||
return useContext(LoggedInContext);
|
return useContext(LoggedInContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function saveLoggedIn(loggedIn: boolean) {
|
||||||
|
console.log("saveLoggedIn", JSON.stringify(loggedIn))
|
||||||
|
window.sessionStorage.setItem(LOGGED_IN_KEY, JSON.stringify(loggedIn));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSavedLoggedIn(): boolean {
|
||||||
|
try {
|
||||||
|
const loggedIn = JSON.parse(window.sessionStorage.getItem(LOGGED_IN_KEY));
|
||||||
|
return !!loggedIn;
|
||||||
|
} catch(err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,6 @@ import './sass/_all.scss';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { App } from './components/App';
|
import { App } from './components/App';
|
||||||
import { client } from './gql/client';
|
|
||||||
|
|
||||||
import '@fortawesome/fontawesome-free/js/fontawesome'
|
import '@fortawesome/fontawesome-free/js/fontawesome'
|
||||||
import '@fortawesome/fontawesome-free/js/solid'
|
import '@fortawesome/fontawesome-free/js/solid'
|
||||||
|
@ -12,8 +11,6 @@ import './resources/favicon.png';
|
||||||
import { ApolloProvider } from '@apollo/client';
|
import { ApolloProvider } from '@apollo/client';
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<ApolloProvider client={client}>
|
<App />,
|
||||||
<App />
|
|
||||||
</ApolloProvider>,
|
|
||||||
document.getElementById('app')
|
document.getElementById('app')
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
|
||||||
|
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
|
||||||
|
import { Config } from '../config';
|
||||||
|
import { WebSocketLink } from "@apollo/client/link/ws";
|
||||||
|
import { RetryLink } from "@apollo/client/link/retry";
|
||||||
|
import { onError } from "@apollo/client/link/error";
|
||||||
|
import { SubscriptionClient } from "subscriptions-transport-ws";
|
||||||
|
import { User } from '../types/user';
|
||||||
|
|
||||||
|
export function createClient(setLoggedIn: (boolean) => void) {
|
||||||
|
const subscriptionClient = new SubscriptionClient(Config.subscriptionEndpoint, {
|
||||||
|
reconnect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorLink = onError(({ operation }) => {
|
||||||
|
const { response } = operation.getContext();
|
||||||
|
if (response.status === 401) setLoggedIn(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryLink = new RetryLink({attempts: {max: 2}}).split(
|
||||||
|
(operation) => operation.operationName === 'subscription',
|
||||||
|
new WebSocketLink(subscriptionClient),
|
||||||
|
new HttpLink({
|
||||||
|
uri: Config.graphQLEndpoint,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const cache = new InMemoryCache({
|
||||||
|
typePolicies: {
|
||||||
|
Workgroup: {
|
||||||
|
fields: {
|
||||||
|
members: {
|
||||||
|
merge: mergeArrayByField<User>("id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new ApolloClient<any>({
|
||||||
|
cache: cache,
|
||||||
|
link: from([
|
||||||
|
errorLink,
|
||||||
|
retryLink
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeArrayByField<T>(fieldName: string) {
|
||||||
|
return (existing: T[] = [], incoming: T[], { readField, mergeObjects }) => {
|
||||||
|
const merged: any[] = existing ? existing.slice(0) : [];
|
||||||
|
|
||||||
|
const objectFieldToIndex: Record<string, number> = Object.create(null);
|
||||||
|
if (existing) {
|
||||||
|
existing.forEach((obj, index) => {
|
||||||
|
objectFieldToIndex[readField(fieldName, obj)] = index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming.forEach(obj => {
|
||||||
|
const field = readField(fieldName, obj);
|
||||||
|
const index = objectFieldToIndex[field];
|
||||||
|
if (typeof index === "number") {
|
||||||
|
merged[index] = mergeObjects(merged[index], obj);
|
||||||
|
} else {
|
||||||
|
objectFieldToIndex[name] = merged.length;
|
||||||
|
merged.push(obj);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||||
|
"forge.cadoles.com/Cadoles/daddy/internal/voter"
|
||||||
|
|
||||||
"github.com/wader/gormstore"
|
"github.com/wader/gormstore"
|
||||||
|
|
||||||
"forge.cadoles.com/Cadoles/daddy/internal/auth"
|
"forge.cadoles.com/Cadoles/daddy/internal/auth"
|
||||||
|
@ -99,5 +102,11 @@ func getServiceContainer(ctx context.Context, conf *config.Config) (*service.Con
|
||||||
|
|
||||||
ctn.Provide(auth.ServiceName, auth.ServiceProvider(conf.Auth.Rules))
|
ctn.Provide(auth.ServiceName, auth.ServiceProvider(conf.Auth.Rules))
|
||||||
|
|
||||||
|
ctn.Provide(voter.ServiceName, voter.ServiceProvider(
|
||||||
|
voter.StrategyUnanimous,
|
||||||
|
model.NewDecisionSupportFileVoter(),
|
||||||
|
model.NewWorkgroupVoter(),
|
||||||
|
))
|
||||||
|
|
||||||
return ctn, nil
|
return ctn, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -90,7 +90,7 @@ func NewDefault() *Config {
|
||||||
Address: ":8081",
|
Address: ":8081",
|
||||||
CookieAuthenticationKey: "",
|
CookieAuthenticationKey: "",
|
||||||
CookieEncryptionKey: "",
|
CookieEncryptionKey: "",
|
||||||
CookieMaxAge: int((time.Hour * 1).Seconds()), // 1 hour
|
CookieMaxAge: int((time.Hour * 24).Seconds()), // 24 hours
|
||||||
TemplateDir: "template",
|
TemplateDir: "template",
|
||||||
PublicDir: "public",
|
PublicDir: "public",
|
||||||
FrontendURL: "http://localhost:8080",
|
FrontendURL: "http://localhost:8080",
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||||
|
errs "github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleIsAuthorized(ctx context.Context, action string, obj model.AuthorizationObject) (bool, error) {
|
||||||
|
db, err := getDB(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var object interface{}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case obj.WorkgroupID != nil:
|
||||||
|
repo := model.NewWorkgroupRepository(db)
|
||||||
|
|
||||||
|
workgroup, err := repo.Find(ctx, *obj.WorkgroupID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
object = workgroup
|
||||||
|
|
||||||
|
case obj.DecisionSupportFileID != nil:
|
||||||
|
repo := model.NewDSFRepository(db)
|
||||||
|
|
||||||
|
dsf, err := repo.Find(ctx, *obj.DecisionSupportFileID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
object = dsf
|
||||||
|
|
||||||
|
case obj.UserID != nil:
|
||||||
|
repo := model.NewUserRepository(db)
|
||||||
|
|
||||||
|
user, err := repo.Find(ctx, *obj.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return false, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
object = user
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false, errs.WithStack(ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorized, err := isAuthorized(ctx, object, model.Action(action))
|
||||||
|
if err != nil {
|
||||||
|
return false, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return authorized, nil
|
||||||
|
}
|
|
@ -12,6 +12,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleCreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
|
func handleCreateDecisionSupportFile(ctx context.Context, changes *model.DecisionSupportFileChanges) (*model.DecisionSupportFile, error) {
|
||||||
|
authorized, err := isAuthorized(ctx, &model.DecisionSupportFile{}, model.ActionCreate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authorized {
|
||||||
|
return nil, errs.WithStack(ErrForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
ctn := container.Must(ctx)
|
ctn := container.Must(ctx)
|
||||||
db := orm.Must(ctn).DB()
|
db := orm.Must(ctn).DB()
|
||||||
|
|
||||||
|
@ -31,7 +40,21 @@ func handleUpdateDecisionSupportFile(ctx context.Context, id string, changes *mo
|
||||||
|
|
||||||
repo := model.NewDSFRepository(db)
|
repo := model.NewDSFRepository(db)
|
||||||
|
|
||||||
dsf, err := repo.Update(ctx, id, changes)
|
dsf, err := repo.Find(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorized, err := isAuthorized(ctx, dsf, model.ActionUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authorized {
|
||||||
|
return nil, errs.WithStack(ErrForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsf, err = repo.Update(ctx, id, changes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.WithStack(err)
|
return nil, errs.WithStack(err)
|
||||||
}
|
}
|
||||||
|
@ -45,7 +68,25 @@ func handleDecisionSupportFiles(ctx context.Context, filter *model.DecisionSuppo
|
||||||
|
|
||||||
repo := model.NewDSFRepository(db)
|
repo := model.NewDSFRepository(db)
|
||||||
|
|
||||||
return repo.Search(ctx, filter)
|
found, err := repo.Search(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsfs := make([]*model.DecisionSupportFile, 0)
|
||||||
|
|
||||||
|
for _, d := range found {
|
||||||
|
authorized, err := isAuthorized(ctx, d, model.ActionRead)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if authorized {
|
||||||
|
dsfs = append(dsfs, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dsfs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSections(ctx context.Context, dsf *model.DecisionSupportFile) (map[string]interface{}, error) {
|
func handleSections(ctx context.Context, dsf *model.DecisionSupportFile) (map[string]interface{}, error) {
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package graph
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
ErrInvalidInput = errors.New("invalid input")
|
||||||
|
)
|
|
@ -3,6 +3,8 @@ package graph
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"forge.cadoles.com/Cadoles/daddy/internal/voter"
|
||||||
|
|
||||||
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||||
"forge.cadoles.com/Cadoles/daddy/internal/orm"
|
"forge.cadoles.com/Cadoles/daddy/internal/orm"
|
||||||
"forge.cadoles.com/Cadoles/daddy/internal/session"
|
"forge.cadoles.com/Cadoles/daddy/internal/session"
|
||||||
|
@ -46,3 +48,31 @@ func getSessionUser(ctx context.Context) (*model.User, *gorm.DB, error) {
|
||||||
|
|
||||||
return user, db, nil
|
return user, db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAuthorized(ctx context.Context, obj interface{}, action interface{}) (bool, error) {
|
||||||
|
user, _, err := getSessionUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctn, err := container.From(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
voterSrv, err := voter.From(ctn)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := voterSrv.Authorized(ctx, user, obj, action)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decision == voter.Allow {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
|
@ -38,8 +38,15 @@ input DecisionSupportFileFilter {
|
||||||
ids: [ID]
|
ids: [ID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input AuthorizationObject {
|
||||||
|
workgroupId: ID
|
||||||
|
userId: ID
|
||||||
|
decisionSupportFileId: ID
|
||||||
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
userProfile: User
|
userProfile: User
|
||||||
workgroups(filter: WorkgroupsFilter): [Workgroup]!
|
workgroups(filter: WorkgroupsFilter): [Workgroup]!
|
||||||
decisionSupportFiles(filter: DecisionSupportFileFilter): [DecisionSupportFile]!
|
decisionSupportFiles(filter: DecisionSupportFileFilter): [DecisionSupportFile]!
|
||||||
|
isAuthorized(action: String!, object: AuthorizationObject!): Boolean!
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,10 @@ func (r *queryResolver) DecisionSupportFiles(ctx context.Context, filter *model1
|
||||||
return handleDecisionSupportFiles(ctx, filter)
|
return handleDecisionSupportFiles(ctx, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *queryResolver) IsAuthorized(ctx context.Context, action string, object model1.AuthorizationObject) (bool, error) {
|
||||||
|
return handleIsAuthorized(ctx, action, object)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *userResolver) ID(ctx context.Context, obj *model1.User) (string, error) {
|
func (r *userResolver) ID(ctx context.Context, obj *model1.User) (string, error) {
|
||||||
return strconv.FormatUint(uint64(obj.ID), 10), nil
|
return strconv.FormatUint(uint64(obj.ID), 10), nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,10 @@ package graph
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
"forge.cadoles.com/Cadoles/daddy/internal/model"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
errs "github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func handleWorkgroups(ctx context.Context, filter *model.WorkgroupsFilter) ([]*model.Workgroup, error) {
|
func handleWorkgroups(ctx context.Context, filter *model.WorkgroupsFilter) ([]*model.Workgroup, error) {
|
||||||
|
@ -24,20 +24,28 @@ func handleWorkgroups(ctx context.Context, filter *model.WorkgroupsFilter) ([]*m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
workgroups, err := repo.FindWorkgroups(ctx, criteria...)
|
found, err := repo.FindWorkgroups(ctx, criteria...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workgroups := make([]*model.Workgroup, 0)
|
||||||
|
|
||||||
|
for _, wg := range found {
|
||||||
|
authorized, err := isAuthorized(ctx, wg, model.ActionRead)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if authorized {
|
||||||
|
workgroups = append(workgroups, wg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return workgroups, nil
|
return workgroups, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
|
func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
|
||||||
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, db, err := getSessionUser(ctx)
|
user, db, err := getSessionUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
|
@ -45,7 +53,21 @@ func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wor
|
||||||
|
|
||||||
repo := model.NewWorkgroupRepository(db)
|
repo := model.NewWorkgroupRepository(db)
|
||||||
|
|
||||||
workgroup, err := repo.AddUserToWorkgroup(ctx, user.ID, workgroupID)
|
workgroup, err := repo.Find(ctx, rawWorkgroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorized, err := isAuthorized(ctx, workgroup, model.ActionJoin)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authorized {
|
||||||
|
return nil, errs.WithStack(ErrForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
workgroup, err = repo.AddUserToWorkgroup(ctx, user.ID, workgroup.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
@ -53,12 +75,7 @@ func handleJoinWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wor
|
||||||
return workgroup, nil
|
return workgroup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLeaveWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
|
func handleLeaveWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
|
||||||
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, db, err := getSessionUser(ctx)
|
user, db, err := getSessionUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
|
@ -66,7 +83,21 @@ func handleLeaveWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wo
|
||||||
|
|
||||||
repo := model.NewWorkgroupRepository(db)
|
repo := model.NewWorkgroupRepository(db)
|
||||||
|
|
||||||
workgroup, err := repo.RemoveUserFromWorkgroup(ctx, user.ID, workgroupID)
|
workgroup, err := repo.Find(ctx, workgroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorized, err := isAuthorized(ctx, workgroup, model.ActionLeave)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authorized {
|
||||||
|
return nil, errs.WithStack(ErrForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
workgroup, err = repo.RemoveUserFromWorkgroup(ctx, user.ID, workgroup.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
@ -75,6 +106,15 @@ func handleLeaveWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wo
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) (*model.Workgroup, error) {
|
func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges) (*model.Workgroup, error) {
|
||||||
|
authorized, err := isAuthorized(ctx, &model.Workgroup{}, model.ActionCreate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authorized {
|
||||||
|
return nil, errs.WithStack(ErrForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
db, err := getDB(ctx)
|
db, err := getDB(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
|
@ -90,12 +130,7 @@ func handleCreateWorkgroup(ctx context.Context, changes model.WorkgroupChanges)
|
||||||
return workgroup, nil
|
return workgroup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCloseWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Workgroup, error) {
|
func handleCloseWorkgroup(ctx context.Context, workgroupID string) (*model.Workgroup, error) {
|
||||||
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := getDB(ctx)
|
db, err := getDB(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
|
@ -103,7 +138,21 @@ func handleCloseWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wo
|
||||||
|
|
||||||
repo := model.NewWorkgroupRepository(db)
|
repo := model.NewWorkgroupRepository(db)
|
||||||
|
|
||||||
workgroup, err := repo.CloseWorkgroup(ctx, workgroupID)
|
workgroup, err := repo.Find(ctx, workgroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorized, err := isAuthorized(ctx, workgroup, model.ActionClose)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authorized {
|
||||||
|
return nil, errs.WithStack(ErrForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
workgroup, err = repo.CloseWorkgroup(ctx, workgroup.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
@ -111,12 +160,7 @@ func handleCloseWorkgroup(ctx context.Context, rawWorkgroupID string) (*model.Wo
|
||||||
return workgroup, nil
|
return workgroup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUpdateWorkgroup(ctx context.Context, rawWorkgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) {
|
func handleUpdateWorkgroup(ctx context.Context, workgroupID string, changes model.WorkgroupChanges) (*model.Workgroup, error) {
|
||||||
workgroupID, err := parseWorkgroupID(rawWorkgroupID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := getDB(ctx)
|
db, err := getDB(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
|
@ -124,19 +168,24 @@ func handleUpdateWorkgroup(ctx context.Context, rawWorkgroupID string, changes m
|
||||||
|
|
||||||
repo := model.NewWorkgroupRepository(db)
|
repo := model.NewWorkgroupRepository(db)
|
||||||
|
|
||||||
workgroup, err := repo.UpdateWorkgroup(ctx, workgroupID, changes)
|
workgroup, err := repo.Find(ctx, workgroupID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorized, err := isAuthorized(ctx, workgroup, model.ActionUpdate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authorized {
|
||||||
|
return nil, errs.WithStack(ErrForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
workgroup, err = repo.UpdateWorkgroup(ctx, workgroup.ID, changes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return workgroup, nil
|
return workgroup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseWorkgroupID(workgroupID string) (uint, error) {
|
|
||||||
workgroupID64, err := strconv.ParseUint(workgroupID, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return 0, errors.WithStack(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return uint(workgroupID64), nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
type Action string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionCreate Action = "create"
|
||||||
|
ActionRead Action = "read"
|
||||||
|
ActionUpdate Action = "update"
|
||||||
|
ActionDelete Action = "delete"
|
||||||
|
ActionJoin Action = "join"
|
||||||
|
ActionLeave Action = "leave"
|
||||||
|
ActionClose Action = "close"
|
||||||
|
)
|
|
@ -88,6 +88,17 @@ func (r *DSFRepository) updateFromChanges(dsf *DecisionSupportFile, changes *Dec
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *DSFRepository) Find(ctx context.Context, id string) (*DecisionSupportFile, error) {
|
||||||
|
dsf := &DecisionSupportFile{}
|
||||||
|
query := r.db.Model(dsf).Preload("Workgroup").Where("id = ?", id)
|
||||||
|
|
||||||
|
if err := query.First(&dsf).Error; err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dsf, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *DSFRepository) Search(ctx context.Context, filter *DecisionSupportFileFilter) ([]*DecisionSupportFile, error) {
|
func (r *DSFRepository) Search(ctx context.Context, filter *DecisionSupportFileFilter) ([]*DecisionSupportFile, error) {
|
||||||
query := r.db.Model(&DecisionSupportFile{}).Preload("Workgroup")
|
query := r.db.Model(&DecisionSupportFile{}).Preload("Workgroup")
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"forge.cadoles.com/Cadoles/daddy/internal/voter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DecisionSupportFileVoter struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *DecisionSupportFileVoter) Vote(ctx context.Context, subject interface{}, obj interface{}, act interface{}) (voter.Decision, error) {
|
||||||
|
user, ok := subject.(*User)
|
||||||
|
if !ok {
|
||||||
|
return voter.Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dsf, ok := obj.(*DecisionSupportFile)
|
||||||
|
if !ok {
|
||||||
|
return voter.Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
action, ok := act.(Action)
|
||||||
|
if !ok {
|
||||||
|
return voter.Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case ActionCreate:
|
||||||
|
return voter.Allow, nil
|
||||||
|
case ActionRead:
|
||||||
|
return voter.Allow, nil
|
||||||
|
case ActionUpdate:
|
||||||
|
if inWorkgroup(user, dsf.Workgroup) {
|
||||||
|
return voter.Allow, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return voter.Deny, nil
|
||||||
|
case ActionDelete:
|
||||||
|
return voter.Deny, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return voter.Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDecisionSupportFileVoter() *DecisionSupportFileVoter {
|
||||||
|
return &DecisionSupportFileVoter{}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
func inWorkgroup(user *User, workgroup *Workgroup) bool {
|
||||||
|
if workgroup == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, w := range user.Workgroups {
|
||||||
|
if w.ID == workgroup.ID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"forge.cadoles.com/Cadoles/daddy/internal/orm"
|
"forge.cadoles.com/Cadoles/daddy/internal/orm"
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
errs "github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserRepository struct {
|
type UserRepository struct {
|
||||||
|
@ -68,6 +69,17 @@ func (r *UserRepository) UpdateUserByEmail(ctx context.Context, email string, ch
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Find(ctx context.Context, id string) (*User, error) {
|
||||||
|
user := &User{}
|
||||||
|
query := r.db.Model(user).Where("id = ?", id)
|
||||||
|
|
||||||
|
if err := query.First(&user).Error; err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||||
return &UserRepository{db}
|
return &UserRepository{db}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/jinzhu/gorm"
|
"github.com/jinzhu/gorm"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
errs "github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkgroupRepository struct {
|
type WorkgroupRepository struct {
|
||||||
|
@ -47,7 +48,7 @@ func (r *WorkgroupRepository) CreateWorkgroup(ctx context.Context, changes Workg
|
||||||
Name: changes.Name,
|
Name: changes.Name,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.db.Model(&Workgroup{}).Create(workgroup).Error; err != nil {
|
if err := r.db.Model(&Workgroup{}).Preload("Members").Create(workgroup).Error; err != nil {
|
||||||
return nil, errors.WithStack(err)
|
return nil, errors.WithStack(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,6 +136,17 @@ func (r *WorkgroupRepository) RemoveUserFromWorkgroup(ctx context.Context, userI
|
||||||
return workgroup, nil
|
return workgroup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *WorkgroupRepository) Find(ctx context.Context, id string) (*Workgroup, error) {
|
||||||
|
wg := &Workgroup{}
|
||||||
|
query := r.db.Model(wg).Preload("Members").Where("id = ?", id)
|
||||||
|
|
||||||
|
if err := query.First(&wg).Error; err != nil {
|
||||||
|
return nil, errs.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wg, nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewWorkgroupRepository(db *gorm.DB) *WorkgroupRepository {
|
func NewWorkgroupRepository(db *gorm.DB) *WorkgroupRepository {
|
||||||
return &WorkgroupRepository{db}
|
return &WorkgroupRepository{db}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"forge.cadoles.com/Cadoles/daddy/internal/voter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkgroupVoter struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *WorkgroupVoter) Vote(ctx context.Context, subject interface{}, obj interface{}, act interface{}) (voter.Decision, error) {
|
||||||
|
user, ok := subject.(*User)
|
||||||
|
if !ok {
|
||||||
|
return voter.Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workgroup, ok := obj.(*Workgroup)
|
||||||
|
if !ok {
|
||||||
|
return voter.Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
action, ok := act.(Action)
|
||||||
|
if !ok {
|
||||||
|
return voter.Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case ActionCreate:
|
||||||
|
return voter.Allow, nil
|
||||||
|
case ActionRead:
|
||||||
|
return voter.Allow, nil
|
||||||
|
case ActionJoin:
|
||||||
|
return voter.Allow, nil
|
||||||
|
case ActionLeave:
|
||||||
|
fallthrough
|
||||||
|
case ActionUpdate:
|
||||||
|
fallthrough
|
||||||
|
case ActionClose:
|
||||||
|
if inWorkgroup(user, workgroup) {
|
||||||
|
return voter.Allow, nil
|
||||||
|
} else {
|
||||||
|
return voter.Deny, nil
|
||||||
|
}
|
||||||
|
case ActionDelete:
|
||||||
|
return voter.Deny, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return voter.Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkgroupVoter() *WorkgroupVoter {
|
||||||
|
return &WorkgroupVoter{}
|
||||||
|
}
|
|
@ -36,10 +36,12 @@ func Mount(r *chi.Mux, config *config.Config) error {
|
||||||
}).Handler)
|
}).Handler)
|
||||||
r.Use(session.UserEmailMiddleware)
|
r.Use(session.UserEmailMiddleware)
|
||||||
|
|
||||||
|
gqlConfig := generated.Config{
|
||||||
|
Resolvers: &graph.Resolver{},
|
||||||
|
}
|
||||||
|
|
||||||
gql := handler.New(
|
gql := handler.New(
|
||||||
generated.NewExecutableSchema(generated.Config{
|
generated.NewExecutableSchema(gqlConfig),
|
||||||
Resolvers: &graph.Resolver{},
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
gql.AddTransport(transport.POST{})
|
gql.AddTransport(transport.POST{})
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
package voter
|
||||||
|
|
||||||
|
type Decision int
|
||||||
|
|
||||||
|
const (
|
||||||
|
Allow Decision = iota
|
||||||
|
Deny
|
||||||
|
Abstain
|
||||||
|
)
|
||||||
|
|
||||||
|
func AsString(d Decision) string {
|
||||||
|
switch d {
|
||||||
|
case Allow:
|
||||||
|
return "allow"
|
||||||
|
case Deny:
|
||||||
|
return "deny"
|
||||||
|
case Abstain:
|
||||||
|
return "abstain"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
package voter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gitlab.com/wpetit/goweb/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Voter interface {
|
||||||
|
Vote(ctx context.Context, subject interface{}, obj interface{}, action interface{}) (Decision, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Strategy func(ctx context.Context, decisions []Decision) (Decision, error)
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
strategy Strategy
|
||||||
|
voters []Voter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Authorized(ctx context.Context, subject interface{}, obj interface{}, action interface{}) (Decision, error) {
|
||||||
|
decisions := make([]Decision, 0, len(m.voters))
|
||||||
|
|
||||||
|
logger.Debug(
|
||||||
|
ctx,
|
||||||
|
"checking authorization",
|
||||||
|
logger.F("subject", subject),
|
||||||
|
logger.F("object", obj),
|
||||||
|
logger.F("action", action),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, v := range m.voters {
|
||||||
|
dec, err := v.Vote(ctx, subject, obj, action)
|
||||||
|
if err != nil {
|
||||||
|
return Deny, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decisions = append(decisions, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := m.strategy(ctx, decisions)
|
||||||
|
if err != nil {
|
||||||
|
return Deny, errors.WithStack(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug(
|
||||||
|
ctx,
|
||||||
|
"authorization checked",
|
||||||
|
logger.F("subject", subject),
|
||||||
|
logger.F("object", obj),
|
||||||
|
logger.F("action", action),
|
||||||
|
logger.F("result", AsString(result)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(strategy Strategy, voters ...Voter) *Manager {
|
||||||
|
return &Manager{strategy, voters}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
package voter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitlab.com/wpetit/goweb/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ServiceProvider(strategy Strategy, voters ...Voter) service.Provider {
|
||||||
|
manager := NewManager(strategy, voters...)
|
||||||
|
|
||||||
|
return func(ctn *service.Container) (interface{}, error) {
|
||||||
|
return manager, nil
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
package voter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gitlab.com/wpetit/goweb/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ServiceName service.Name = "voter"
|
||||||
|
|
||||||
|
// From retrieves the voter service in the given container.
|
||||||
|
func From(container *service.Container) (*Manager, error) {
|
||||||
|
service, err := container.Service(ServiceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, ok := service.(*Manager)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return srv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must retrieves the voter service in the given container or panic otherwise.
|
||||||
|
func Must(container *service.Container) *Manager {
|
||||||
|
srv, err := From(container)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
package voter
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// StrategyUnanimous returns Allow if all voters allow the operations.
|
||||||
|
func StrategyUnanimous(ctx context.Context, decisions []Decision) (Decision, error) {
|
||||||
|
allAbstains := true
|
||||||
|
|
||||||
|
for _, d := range decisions {
|
||||||
|
if d == Deny {
|
||||||
|
return Deny, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if d != Abstain {
|
||||||
|
allAbstains = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allAbstains {
|
||||||
|
return Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Allow, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrategyAffirmative returns Allow if at least one voter allow the operation.
|
||||||
|
func StrategyAffirmative(ctx context.Context, decisions []Decision) (Decision, error) {
|
||||||
|
allAbstains := true
|
||||||
|
|
||||||
|
for _, d := range decisions {
|
||||||
|
if d == Allow {
|
||||||
|
return Allow, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if allAbstains && d != Abstain {
|
||||||
|
allAbstains = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allAbstains {
|
||||||
|
return Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Deny, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrategyConsensus returns Allow if the majority of voters allow the operation.
|
||||||
|
func StrategyConsensus(ctx context.Context, decisions []Decision) (Decision, error) {
|
||||||
|
deny := 0
|
||||||
|
allow := 0
|
||||||
|
abstain := 0
|
||||||
|
|
||||||
|
for _, d := range decisions {
|
||||||
|
switch {
|
||||||
|
case d == Allow:
|
||||||
|
allow++
|
||||||
|
case d == Deny:
|
||||||
|
deny++
|
||||||
|
case d == Abstain:
|
||||||
|
abstain++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if abstain > allow && abstain > deny {
|
||||||
|
return Abstain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if allow > abstain && allow > deny {
|
||||||
|
return Allow, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if deny > allow && deny > abstain {
|
||||||
|
return Deny, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Abstain, nil
|
||||||
|
}
|
|
@ -0,0 +1,125 @@
|
||||||
|
package voter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStrategyUnanimous(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
Decisions []Decision
|
||||||
|
Expect Decision
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Allow, Allow, Allow},
|
||||||
|
Expect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Abstain, Abstain, Abstain},
|
||||||
|
Expect: Abstain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Deny, Abstain, Abstain},
|
||||||
|
Expect: Deny,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Deny, Allow, Abstain},
|
||||||
|
Expect: Deny,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := StrategyUnanimous(ctx, tc.Decisions)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if e, g := tc.Expect, result; e != g {
|
||||||
|
t.Errorf("result: expected '%v', got '%v'", AsString(e), AsString(g))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrategyAffirmative(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
Decisions []Decision
|
||||||
|
Expect Decision
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Allow, Allow, Allow},
|
||||||
|
Expect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Abstain, Abstain, Abstain},
|
||||||
|
Expect: Abstain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Deny, Abstain, Abstain},
|
||||||
|
Expect: Deny,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Deny, Allow, Abstain},
|
||||||
|
Expect: Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := StrategyAffirmative(ctx, tc.Decisions)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if e, g := tc.Expect, result; e != g {
|
||||||
|
t.Errorf("result: expected '%v', got '%v'", AsString(e), AsString(g))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrategyConsensus(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
Decisions []Decision
|
||||||
|
Expect Decision
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Allow, Allow, Allow},
|
||||||
|
Expect: Allow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Abstain, Abstain, Abstain},
|
||||||
|
Expect: Abstain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Deny, Allow, Abstain},
|
||||||
|
Expect: Abstain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Deny, Deny, Allow},
|
||||||
|
Expect: Deny,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Deny, Deny, Allow, Allow},
|
||||||
|
Expect: Abstain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Decisions: []Decision{Deny, Deny, Allow, Allow, Allow},
|
||||||
|
Expect: Allow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := StrategyConsensus(ctx, tc.Decisions)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if e, g := tc.Expect, result; e != g {
|
||||||
|
t.Errorf("result: expected '%v', got '%v'", AsString(e), AsString(g))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue