ACL for mcs (#123)

This PR sets the initial version of the ACL for mcs, the idea behind
this is to start using the principle of least privileges when assigning
policies to users when creating users through mcs, currently mcsAdmin policy uses admin:*
and s3:* and by default a user with that policy will have access to everything, if want to limit
that we can create a policy with least privileges.

We need to start validating explicitly if users has acccess to an
specific endpoint based on IAM policy actions.

In this first version every endpoint (you can see it as a page to),
defines a set of well defined admin/s3 actions to work properly, ie:

```
// corresponds to /groups endpoint used by the groups page
var groupsActionSet = iampolicy.NewActionSet(
    iampolicy.ListGroupsAdminAction,
    iampolicy.AddUserToGroupAdminAction,
    //iampolicy.GetGroupAdminAction,
    iampolicy.EnableGroupAdminAction,
    iampolicy.DisableGroupAdminAction,
)

// corresponds to /policies endpoint used by the policies page
var iamPoliciesActionSet = iampolicy.NewActionSet(
    iampolicy.GetPolicyAdminAction,
    iampolicy.DeletePolicyAdminAction,
    iampolicy.CreatePolicyAdminAction,
    iampolicy.AttachPolicyAdminAction,
    iampolicy.ListUserPoliciesAdminAction,
)
```
With that said, for this initial version, now the sessions endpoint will
return a list of authorized pages to be render on the UI, on subsequent
prs we will add this verification of authorization via a server
middleware.
This commit is contained in:
Lenin Alevski
2020-05-18 18:03:06 -07:00
committed by GitHub
parent e8491d80cb
commit 732e0ef683
23 changed files with 1080 additions and 335 deletions

View File

@@ -22,7 +22,7 @@ assets:
test:
@(go test -race -v github.com/minio/mcs/restapi/...)
@(go test -race -v github.com/minio/mcs/pkg/auth/...)
@(go test -race -v github.com/minio/mcs/pkg/...)
coverage:
@(go test -v -coverprofile=coverage.out github.com/minio/mcs/restapi/... && go tool cover -html=coverage.out && open coverage.html)

View File

@@ -14,31 +14,30 @@ $ mc admin user add myminio mcs YOURMCSSECRET
$ set -o history
```
2. Create a policy for `mcs`
2. Create a policy for `mcs` with access to everything (for testing and debugging)
```
$ cat > mcsAdmin.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"admin:*"
],
"Effect": "Allow",
"Sid": ""
},
{
"Action": [
"s3:*"
],
"Effect": "Allow",
"Resource": [
"arn:aws:s3:::*"
],
"Sid": ""
}
]
"Version": "2012-10-17",
"Statement": [{
"Action": [
"admin:*"
],
"Effect": "Allow",
"Sid": ""
},
{
"Action": [
"s3:*"
],
"Effect": "Allow",
"Resource": [
"arn:aws:s3:::*"
],
"Sid": ""
}
]
}
EOF
$ mc admin policy add myminio mcsAdmin mcsAdmin.json
@@ -50,6 +49,49 @@ $ mc admin policy add myminio mcsAdmin mcsAdmin.json
$ mc admin policy set myminio mcsAdmin user=mcs
```
### Note
Additionally, you can create policies to limit the privileges for `mcs` users, for example, if you want the user to only have access to dashboard, buckets, notifications and watch page, the policy should look like this:
```
{
"Version": "2012-10-17",
"Statement": [{
"Action": [
"admin:ServerInfo",
],
"Effect": "Allow",
"Sid": ""
},
{
"Action": [
"s3:ListenBucketNotification",
"s3:PutBucketNotification",
"s3:GetBucketNotification",
"s3:ListMultipartUploadParts",
"s3:ListBucketMultipartUploads",
"s3:ListBucket",
"s3:HeadBucket",
"s3:GetObject",
"s3:GetBucketLocation",
"s3:AbortMultipartUpload",
"s3:CreateBucket",
"s3:PutObject",
"s3:DeleteObject",
"s3:DeleteBucket",
"s3:PutBucketPolicy",
"s3:DeleteBucketPolicy",
"s3:GetBucketPolicy"
],
"Effect": "Allow",
"Resource": [
"arn:aws:s3:::*"
],
"Sid": ""
}
]
}
```
## Run MCS server
To run the server:

View File

@@ -36,6 +36,9 @@ import (
// swagger:model sessionResponse
type SessionResponse struct {
// pages
Pages []string `json:"pages"`
// status
// Enum: [ok]
Status string `json:"status,omitempty"`

268
pkg/acl/endpoints.go Normal file
View File

@@ -0,0 +1,268 @@
// This file is part of MinIO Console Server
// Copyright (c) 2020 MinIO, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package acl
import iampolicy "github.com/minio/minio/pkg/iam/policy"
// endpoints definition
var (
configuration = "/configurations-list"
users = "/users"
groups = "/groups"
iamPolicies = "/policies"
dashboard = "/dashboard"
profiling = "/profiling"
trace = "/trace"
logs = "/logs"
watch = "/watch"
notifications = "/notification-endpoints"
buckets = "/buckets"
)
type ConfigurationActionSet struct {
actionTypes iampolicy.ActionSet
actions iampolicy.ActionSet
}
// configurationActionSet contains the list of admin actions required for this endpoint to work
var configurationActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllAdminActions,
),
actions: iampolicy.NewActionSet(
iampolicy.ConfigUpdateAdminAction,
),
}
// logsActionSet contains the list of admin actions required for this endpoint to work
var logsActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllAdminActions,
),
actions: iampolicy.NewActionSet(
iampolicy.ConsoleLogAdminAction,
),
}
// dashboardActionSet contains the list of admin actions required for this endpoint to work
var dashboardActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllAdminActions,
),
actions: iampolicy.NewActionSet(
iampolicy.ServerInfoAdminAction,
),
}
// groupsActionSet contains the list of admin actions required for this endpoint to work
var groupsActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllAdminActions,
),
actions: iampolicy.NewActionSet(
iampolicy.ListGroupsAdminAction,
iampolicy.AddUserToGroupAdminAction,
//iampolicy.GetGroupAdminAction,
iampolicy.EnableGroupAdminAction,
iampolicy.DisableGroupAdminAction,
),
}
// iamPoliciesActionSet contains the list of admin actions required for this endpoint to work
var iamPoliciesActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllAdminActions,
),
actions: iampolicy.NewActionSet(
iampolicy.GetPolicyAdminAction,
iampolicy.DeletePolicyAdminAction,
iampolicy.CreatePolicyAdminAction,
iampolicy.AttachPolicyAdminAction,
iampolicy.ListUserPoliciesAdminAction,
),
}
// profilingActionSet contains the list of admin actions required for this endpoint to work
var profilingActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllAdminActions,
),
actions: iampolicy.NewActionSet(
iampolicy.ProfilingAdminAction,
),
}
// traceActionSet contains the list of admin actions required for this endpoint to work
var traceActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllAdminActions,
),
actions: iampolicy.NewActionSet(
iampolicy.TraceAdminAction,
),
}
// usersActionSet contains the list of admin actions required for this endpoint to work
var usersActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllAdminActions,
),
actions: iampolicy.NewActionSet(
iampolicy.ListUsersAdminAction,
iampolicy.CreateUserAdminAction,
iampolicy.DeleteUserAdminAction,
iampolicy.GetUserAdminAction,
iampolicy.EnableUserAdminAction,
iampolicy.DisableUserAdminAction,
),
}
// watchActionSet contains the list of admin actions required for this endpoint to work
var watchActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllAdminActions,
),
actions: iampolicy.NewActionSet(
iampolicy.ListenBucketNotificationAction,
),
}
// notificationsActionSet contains the list of admin actions required for this endpoint to work
var notificationsActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllActions,
),
actions: iampolicy.NewActionSet(
iampolicy.ListenBucketNotificationAction,
iampolicy.PutBucketNotificationAction,
iampolicy.GetBucketNotificationAction,
),
}
// bucketsActionSet contains the list of admin actions required for this endpoint to work
var bucketsActionSet = ConfigurationActionSet{
actionTypes: iampolicy.NewActionSet(
iampolicy.AllActions,
),
actions: iampolicy.NewActionSet(
// Read access to buckets
iampolicy.ListMultipartUploadPartsAction,
iampolicy.ListBucketMultipartUploadsAction,
iampolicy.ListBucketAction,
iampolicy.HeadBucketAction,
iampolicy.GetObjectAction,
iampolicy.GetBucketLocationAction,
// Write access to buckets
iampolicy.AbortMultipartUploadAction,
iampolicy.CreateBucketAction,
iampolicy.PutObjectAction,
iampolicy.DeleteObjectAction,
iampolicy.DeleteBucketAction,
// Assign bucket policies
iampolicy.PutBucketPolicyAction,
iampolicy.DeleteBucketPolicyAction,
iampolicy.GetBucketPolicyAction,
),
}
// endpointRules contains the mapping between endpoints and ActionSets, additional rules can be added here
var endpointRules = map[string]ConfigurationActionSet{
configuration: configurationActionSet,
users: usersActionSet,
groups: groupsActionSet,
iamPolicies: iamPoliciesActionSet,
dashboard: dashboardActionSet,
profiling: profilingActionSet,
trace: traceActionSet,
logs: logsActionSet,
watch: watchActionSet,
notifications: notificationsActionSet,
buckets: bucketsActionSet,
}
// GetActionsStringFromPolicy extract the admin/s3 actions from a given policy and return them in []string format
//
// ie:
// {
// "Version": "2012-10-17",
// "Statement": [{
// "Action": [
// "admin:ServerInfo",
// "admin:CreatePolicy",
// "admin:GetUser"
// ],
// ...
// },
// {
// "Action": [
// "s3:ListenBucketNotification",
// "s3:PutBucketNotification"
// ],
// ...
// }
// ]
// }
// Will produce an array like: ["admin:ServerInfo", "admin:CreatePolicy", "admin:GetUser", "s3:ListenBucketNotification", "s3:PutBucketNotification"]\
func GetActionsStringFromPolicy(policy *iampolicy.Policy) []string {
var actions []string
for _, statement := range policy.Statements {
// We only care about allowed actions
if statement.Effect.IsAllowed(true) {
for _, action := range statement.Actions.ToSlice() {
actions = append(actions, string(action))
}
}
}
return actions
}
// actionsStringToActionSet convert a given string array to iampolicy.ActionSet structure
// this avoids ending with duplicate actions
func actionsStringToActionSet(actions []string) iampolicy.ActionSet {
actionsSet := iampolicy.ActionSet{}
for _, action := range actions {
actionsSet.Add(iampolicy.Action(action))
}
return actionsSet
}
// GetAuthorizedEndpoints return a list of allowed endpoint based on a provided *iampolicy.Policy
// ie: pages the user should have access based on his current privileges
func GetAuthorizedEndpoints(actions []string) []string {
if len(actions) == 0 {
return []string{}
}
// Prepare new ActionSet structure that will hold all the user actions
userAllowedAction := actionsStringToActionSet(actions)
allowedEndpoints := []string{}
for endpoint, rules := range endpointRules {
// check if user policy matches s3:* or admin:* typesIntersection
endpointActionTypes := rules.actionTypes
typesIntersection := endpointActionTypes.Intersection(userAllowedAction)
if len(typesIntersection) == len(endpointActionTypes.ToSlice()) {
allowedEndpoints = append(allowedEndpoints, endpoint)
continue
}
// check if user policy matches explicitly defined endpoint required actions
endpointRequiredActions := rules.actions
actionsIntersection := endpointRequiredActions.Intersection(userAllowedAction)
if len(actionsIntersection) == len(endpointRequiredActions.ToSlice()) {
allowedEndpoints = append(allowedEndpoints, endpoint)
}
}
return allowedEndpoints
}

138
pkg/acl/endpoints_test.go Normal file
View File

@@ -0,0 +1,138 @@
// This file is part of MinIO Orchestrator
// Copyright (c) 2020 MinIO, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package acl
import (
"reflect"
"testing"
iampolicy "github.com/minio/minio/pkg/iam/policy"
)
func TestGetAuthorizedEndpoints(t *testing.T) {
type args struct {
actions []string
}
tests := []struct {
name string
args args
want int
}{
{
name: "dashboard endpoint",
args: args{
[]string{"admin:ServerInfo"},
},
want: 1,
},
{
name: "policies endpoint",
args: args{
[]string{
"admin:CreatePolicy",
"admin:DeletePolicy",
"admin:GetPolicy",
"admin:AttachUserOrGroupPolicy",
"admin:ListUserPolicies",
},
},
want: 1,
},
{
name: "all admin endpoints",
args: args{
[]string{
"admin:*",
},
},
want: 9,
},
{
name: "all s3 endpoints",
args: args{
[]string{
"s3:*",
},
},
want: 2,
},
{
name: "all admin and s3 endpoints",
args: args{
[]string{
"admin:*",
"s3:*",
},
},
want: 11,
},
{
name: "no endpoints",
args: args{
[]string{},
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetAuthorizedEndpoints(tt.args.actions); !reflect.DeepEqual(len(got), tt.want) {
t.Errorf("GetAuthorizedEndpoints() = %v, want %v", len(got), tt.want)
}
})
}
}
func TestGetActionsStringFromPolicy(t *testing.T) {
type args struct {
policy *iampolicy.Policy
}
tests := []struct {
name string
args args
want int
}{
{
name: "parse ReadOnly policy",
args: args{
policy: &iampolicy.ReadOnly,
},
want: 2,
},
{
name: "parse WriteOnly policy",
args: args{
policy: &iampolicy.WriteOnly,
},
want: 1,
},
{
name: "parse AdminDiagnostics policy",
args: args{
policy: &iampolicy.AdminDiagnostics,
},
want: 6,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetActionsStringFromPolicy(tt.args.policy); !reflect.DeepEqual(len(got), tt.want) {
t.Errorf("GetActionsStringFromPolicy() = %v, want %v", len(got), tt.want)
}
})
}
}

View File

@@ -57,6 +57,7 @@ type DecryptedClaims struct {
AccessKeyID string
SecretAccessKey string
SessionToken string
Actions []string
}
// JWTAuthenticate takes a jwt, decode it, extract claims and validate the signature
@@ -93,9 +94,9 @@ func JWTAuthenticate(token string) (*DecryptedClaims, error) {
// NewJWTWithClaimsForClient generates a new jwt with claims based on the provided STS credentials, first
// encrypts the claims and the sign them
func NewJWTWithClaimsForClient(credentials *credentials.Value, audience string) (string, error) {
func NewJWTWithClaimsForClient(credentials *credentials.Value, actions []string, audience string) (string, error) {
if credentials != nil {
encryptedClaims, err := encryptClaims(credentials.AccessKeyID, credentials.SecretAccessKey, credentials.SessionToken)
encryptedClaims, err := encryptClaims(credentials.AccessKeyID, credentials.SecretAccessKey, credentials.SessionToken, actions)
if err != nil {
return "", err
}
@@ -112,8 +113,8 @@ func NewJWTWithClaimsForClient(credentials *credentials.Value, audience string)
// encryptClaims() receives the 3 STS claims, concatenate them and encrypt them using AES-GCM
// returns a base64 encoded ciphertext
func encryptClaims(accessKeyID, secretAccessKey, sessionToken string) (string, error) {
payload := []byte(fmt.Sprintf("%s:%s:%s", accessKeyID, secretAccessKey, sessionToken))
func encryptClaims(accessKeyID, secretAccessKey, sessionToken string, actions []string) (string, error) {
payload := []byte(fmt.Sprintf("%s#%s#%s#%s", accessKeyID, secretAccessKey, sessionToken, strings.Join(actions, ",")))
ciphertext, err := encrypt(payload)
if err != nil {
return "", err
@@ -133,16 +134,18 @@ func decryptClaims(ciphertext string) (*DecryptedClaims, error) {
log.Println(err)
return nil, errClaimsFormat
}
s := strings.Split(string(plaintext), ":")
s := strings.Split(string(plaintext), "#")
// Validate that the decrypted string has the right format "accessKeyID:secretAccessKey:sessionToken"
if len(s) != 3 {
if len(s) != 4 {
return nil, errClaimsFormat
}
accessKeyID, secretAccessKey, sessionToken := s[0], s[1], s[2]
accessKeyID, secretAccessKey, sessionToken, actions := s[0], s[1], s[2], s[3]
actionsList := strings.Split(actions, ",")
return &DecryptedClaims{
AccessKeyID: accessKeyID,
SecretAccessKey: secretAccessKey,
SessionToken: sessionToken,
Actions: actionsList,
}, nil
}

View File

@@ -37,14 +37,14 @@ func TestNewJWTWithClaimsForClient(t *testing.T) {
funcAssert := assert.New(t)
// Test-1 : NewJWTWithClaimsForClient() is generated correctly without errors
function := "NewJWTWithClaimsForClient()"
jwt, err := NewJWTWithClaimsForClient(creds, audience)
jwt, err := NewJWTWithClaimsForClient(creds, []string{""}, audience)
if err != nil || jwt == "" {
t.Errorf("Failed on %s:, error occurred: %s", function, err)
}
// saving jwt for future tests
goodToken = jwt
// Test-2 : NewJWTWithClaimsForClient() throws error because of empty credentials
if _, err = NewJWTWithClaimsForClient(nil, audience); err != nil {
if _, err = NewJWTWithClaimsForClient(nil, []string{""}, audience); err != nil {
funcAssert.Equal("provided credentials are empty", err.Error())
}
}

File diff suppressed because one or more lines are too long

View File

@@ -14,7 +14,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import React, { useEffect } from "react";
import clsx from "clsx";
import {
createStyles,
@@ -28,7 +28,6 @@ import Box from "@material-ui/core/Box";
import Typography from "@material-ui/core/Typography";
import Container from "@material-ui/core/Container";
import Link from "@material-ui/core/Link";
import history from "../../history";
import {
Redirect,
@@ -64,6 +63,8 @@ import WebhookPanel from "./Configurations/ConfigurationPanels/WebhookPanel";
import Trace from "./Trace/Trace";
import Logs from "./Logs/Logs";
import Watch from "./Watch/Watch";
import { ISessionResponse } from "./types";
import { saveSessionResponse } from "./actions";
function Copyright() {
return (
@@ -172,18 +173,6 @@ const styles = (theme: Theme) =>
},
});
const mapState = (state: AppState) => ({
open: state.system.sidebarOpen,
needsRestart: state.system.serverNeedsRestart,
isServerLoading: state.system.serverIsLoading,
});
const connector = connect(mapState, {
setMenuOpen,
serverNeedsRestart,
serverIsLoading,
});
interface IConsoleProps {
open: boolean;
needsRestart: boolean;
@@ -193,143 +182,200 @@ interface IConsoleProps {
setMenuOpen: typeof setMenuOpen;
serverNeedsRestart: typeof serverNeedsRestart;
serverIsLoading: typeof serverIsLoading;
saveSessionResponse: typeof saveSessionResponse;
session: ISessionResponse;
}
class Console extends React.Component<
IConsoleProps & RouteComponentProps & StyledProps & ThemedComponentProps
> {
componentDidMount(): void {
const Console = ({
classes,
open,
needsRestart,
isServerLoading,
serverNeedsRestart,
serverIsLoading,
saveSessionResponse,
session,
}: IConsoleProps) => {
useEffect(() => {
api
.invoke("GET", `/api/v1/session`)
.then((res) => {
console.log(res);
saveSessionResponse(res);
})
.catch((err) => {
storage.removeItem("token");
history.push("/");
});
}
}, [saveSessionResponse]);
restartServer() {
this.props.serverIsLoading(true);
const restartServer = () => {
serverIsLoading(true);
api
.invoke("POST", "/api/v1/service/restart", {})
.then((res) => {
console.log("success restarting service");
console.log(res);
this.props.serverIsLoading(false);
this.props.serverNeedsRestart(false);
serverIsLoading(false);
serverNeedsRestart(false);
})
.catch((err) => {
this.props.serverIsLoading(false);
serverIsLoading(false);
console.log("failure restarting service");
console.log(err);
});
}
};
const allowedPages = session.pages.reduce(
(result: any, item: any, index: any) => {
result[item] = true;
return result;
},
{}
);
const routes = [
{
component: Dashboard,
path: "/dashboard",
},
{
component: Buckets,
path: "/buckets",
},
{
component: Watch,
path: "/watch",
},
{
component: Users,
path: "/users",
},
{
component: Groups,
path: "/groups",
},
{
component: Policies,
path: "/policies",
},
{
component: Trace,
path: "/trace",
},
{
component: Logs,
path: "/logs",
},
{
component: ListNotificationEndpoints,
path: "/notification-endpoints",
},
{
component: ConfigurationsList,
path: "/configurations-list",
},
{
component: Permissions,
path: "/permissions",
},
{
component: ServiceAccounts,
path: "/service_accounts",
},
{
component: WebhookPanel,
path: "/webhook/logger",
},
{
component: WebhookPanel,
path: "/webhook/audit",
},
];
const allowedRoutes = routes.filter((route: any) => allowedPages[route.path]);
return (
<React.Fragment>
{session.status == "ok" ? (
<div className={classes.root}>
<CssBaseline />
<Drawer
variant="permanent"
classes={{
paper: clsx(
classes.drawerPaper,
!open && classes.drawerPaperClose
),
}}
open={open}
>
<Menu pages={session.pages} />
</Drawer>
render() {
const { classes, open, needsRestart, isServerLoading } = this.props;
return (
<div className={classes.root}>
<CssBaseline />
<Drawer
variant="permanent"
classes={{
paper: clsx(classes.drawerPaper, !open && classes.drawerPaperClose),
}}
open={open}
>
{/*<div className={classes.toolbarIcon}>*/}
{/* <IconButton*/}
{/* onClick={() => {*/}
{/* this.props.setMenuOpen(false);*/}
{/* }}*/}
{/* >*/}
{/* <ChevronLeftIcon />*/}
{/* </IconButton>*/}
{/*</div>*/}
{/*<Divider />*/}
<Menu />
</Drawer>
<main className={classes.content}>
{needsRestart && (
<div className={classes.warningBar}>
{isServerLoading ? (
<React.Fragment>
The server is restarting.
<LinearProgress />
</React.Fragment>
) : (
<main className={classes.content}>
{needsRestart && (
<div className={classes.warningBar}>
{isServerLoading ? (
<React.Fragment>
The server is restarting.
<LinearProgress />
</React.Fragment>
) : (
<React.Fragment>
The instance needs to be restarted for configuration changes
to take effect.{" "}
to take effect.{" "}
<Button
color="secondary"
size="small"
onClick={() => {
this.restartServer();
restartServer();
}}
>
Restart
</Button>
</Button>
</React.Fragment>
)}
</div>
)}
<div className={classes.appBarSpacer} />
<Container maxWidth="lg" className={classes.container}>
<Router history={history}>
<Switch>
<Route path="/buckets" component={Buckets} />
<Route exact path="/permissions" component={Permissions} />
<Route exact path="/policies" component={Policies} />
<Route
exact
path="/service_accounts"
component={ServiceAccounts}
/>
<Route exact path="/users" component={Users} />
<Route exact path="/dashboard" component={Dashboard} />
<Route exct path="/groups" component={Groups} />
<Route
exact
path="/notification-endpoints"
component={ListNotificationEndpoints}
/>
<Route
exact
path="/configurations-list"
component={ConfigurationsList}
/>
<Route exact path="/webhook/logger" component={WebhookPanel} />
<Route exact path="/webhook/audit" component={WebhookPanel} />
<Route exct path="/trace" component={Trace} />
<Route exct path="/logs" component={Logs} />
<Route exct path="/watch" component={Watch} />
<Route exact path="/">
<Redirect to="/dashboard" />
</Route>
<Route component={NotFoundPage} />
</Switch>
</Router>
</div>
)}
<div className={classes.appBarSpacer} />
<Container maxWidth="lg" className={classes.container}>
<Router history={history}>
<Switch>
{allowedRoutes.map((route: any) => (
<Route
key={route.path}
exact
path={route.path}
component={route.component}
/>
))}
{allowedRoutes.length > 0 ? (
<Route exact path="/">
<Redirect to={allowedRoutes[0].path} />
</Route>
) : null}
</Switch>
</Router>
<Box pt={4}>
<Copyright />
</Box>
</Container>
</main>
</div>
);
}
}
<Box pt={4}>
<Copyright />
</Box>
</Container>
</main>
</div>
) : null}
</React.Fragment>
);
};
// );
const mapState = (state: AppState) => ({
open: state.system.sidebarOpen,
needsRestart: state.system.serverNeedsRestart,
isServerLoading: state.system.serverIsLoading,
session: state.console.session,
});
export default withRouter(connector(withStyles(styles)(Console)));
// export default withStyles(styles)(connector(Console));
// export default compose(
// withStyles(styles),
// connector
// )(withRouter(Console))
const connector = connect(mapState, {
setMenuOpen,
serverNeedsRestart,
serverIsLoading,
saveSessionResponse,
});
export default connector(withStyles(styles)(Console));

View File

@@ -18,7 +18,7 @@ import React from "react";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import WebAssetIcon from "@material-ui/icons/WebAsset";
import CenterFocusWeakIcon from '@material-ui/icons/CenterFocusWeak';
import CenterFocusWeakIcon from "@material-ui/icons/CenterFocusWeak";
import ListItemText from "@material-ui/core/ListItemText";
import { NavLink } from "react-router-dom";
import { Divider, Typography, withStyles } from "@material-ui/core";
@@ -93,6 +93,7 @@ const connector = connect(mapState, { userLoggedIn });
interface MenuProps {
classes: any;
userLoggedIn: typeof userLoggedIn;
pages: string[];
}
class Menu extends React.Component<MenuProps> {
@@ -114,77 +115,153 @@ class Menu extends React.Component<MenuProps> {
}
render() {
const { classes } = this.props;
const { classes, pages } = this.props;
const allowedPages = pages.reduce((result: any, item: any, index: any) => {
result[item] = true;
return result;
}, {});
const menu = [
{
type: "item",
component: NavLink,
to: "/dashboard",
name: "Dashboard",
icon: <DashboardIcon />,
},
{
type: "item",
component: NavLink,
to: "/buckets",
name: "Buckets",
icon: <BucketsIcon />,
},
{
type: "item",
component: NavLink,
to: "/watch",
name: "Watch",
icon: <CenterFocusWeakIcon />,
},
{
type: "divider",
key: "divider-1",
},
{
type: "title",
name: "Admin",
component: Typography,
},
{
group: "Admin",
type: "item",
component: NavLink,
to: "/users",
name: "Users",
icon: <PersonIcon />,
},
{
group: "Admin",
type: "item",
component: NavLink,
to: "/groups",
name: "Groups",
icon: <UsersIcon />,
},
{
group: "Admin",
type: "item",
component: NavLink,
to: "/policies",
name: "IAM Policies",
icon: <PermissionIcon />,
},
{
group: "Admin",
type: "item",
component: NavLink,
to: "/trace",
name: "Trace",
icon: <LoopIcon />,
},
{
group: "Admin",
type: "item",
component: NavLink,
to: "/logs",
name: "Console Logs",
icon: <WebAssetIcon />,
},
{
type: "title",
name: "Configuration",
component: Typography,
},
{
group: "Configuration",
type: "item",
component: NavLink,
to: "/notification-endpoints",
name: "Lambda Notifications",
icon: <NotificationsIcon />,
},
{
group: "Configuration",
type: "item",
component: NavLink,
to: "/configurations-list",
name: "Configurations List",
icon: <ListAltIcon />,
},
{
type: "divider",
key: "divider-2",
},
];
const allowedItems = menu.filter(
(item: any) =>
allowedPages[item.to] || item.forceDisplay || item.type !== "item"
);
return (
<React.Fragment>
<div className={classes.logo}>
<img src={logo} alt="logo" />
</div>
<List className={classes.menuList}>
<ListItem button component={NavLink} to="/dashboard">
<ListItemIcon>
<DashboardIcon />
</ListItemIcon>
<ListItemText primary="Dashboard" />
</ListItem>
<ListItem button component={NavLink} to="/buckets">
<ListItemIcon>
<BucketsIcon />
</ListItemIcon>
<ListItemText primary="Buckets" />
</ListItem>
<ListItem button component={NavLink} to="/watch">
<ListItemIcon>
<CenterFocusWeakIcon />
</ListItemIcon>
<ListItemText primary="Watch" />
</ListItem>
<Divider />
<ListItem component={Typography}>Admin</ListItem>
<ListItem button component={NavLink} to="/users">
<ListItemIcon>
<PersonIcon />
</ListItemIcon>
<ListItemText primary="Users" />
</ListItem>
<ListItem button component={NavLink} to="/groups">
<ListItemIcon>
<UsersIcon />
</ListItemIcon>
<ListItemText primary="Groups" />
</ListItem>
<ListItem button component={NavLink} to="/policies">
<ListItemIcon>
<PermissionIcon />
</ListItemIcon>
<ListItemText primary="IAM Policies" />
</ListItem>
<ListItem button component={NavLink} to="/trace">
<ListItemIcon>
<LoopIcon />
</ListItemIcon>
<ListItemText primary="Trace" />
</ListItem>
<ListItem button component={NavLink} to="/logs">
<ListItemIcon>
<WebAssetIcon />
</ListItemIcon>
<ListItemText primary="Console Logs" />
</ListItem>
<ListItem component={Typography}>Configuration</ListItem>
<ListItem button component={NavLink} to="/notification-endpoints">
<ListItemIcon>
<NotificationsIcon />
</ListItemIcon>
<ListItemText primary="Lambda Notifications" />
</ListItem>
<ListItem button component={NavLink} to="/configurations-list">
<ListItemIcon>
<ListAltIcon />
</ListItemIcon>
<ListItemText primary="Configurations List" />
</ListItem>
<Divider />
{allowedItems.map((page: any) => {
switch (page.type) {
case "divider": {
return <Divider key={page.key} />;
}
case "item": {
return (
<ListItem
key={page.to}
button
component={page.component}
to={page.to}
>
{page.icon && <ListItemIcon>{page.icon}</ListItemIcon>}
{page.name && <ListItemText primary={page.name} />}
</ListItem>
);
}
case "title": {
return (
(allowedItems || []).filter(
(item: any) => item.group === page.name
).length > 0 && (
<ListItem key={page.name} component={page.component}>
{page.name}
</ListItem>
)
);
}
default:
}
})}
<ListItem
button
onClick={() => {

View File

@@ -0,0 +1,32 @@
// This file is part of MinIO Console Server
// Copyright (c) 2020 MinIO, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ISessionResponse } from "./types";
export const SESSION_RESPONSE = "SESSION_RESPONSE";
interface SessionAction {
type: typeof SESSION_RESPONSE;
message: ISessionResponse;
}
export type SessionActionTypes = SessionAction;
export function saveSessionResponse(message: ISessionResponse) {
return {
type: SESSION_RESPONSE,
message: message,
};
}

View File

@@ -0,0 +1,44 @@
// This file is part of MinIO Console Server
// Copyright (c) 2020 MinIO, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ISessionResponse } from "./types";
import { SessionActionTypes, SESSION_RESPONSE } from "./actions";
export interface ConsoleState {
session: ISessionResponse;
}
const initialState: ConsoleState = {
session: {
status: "",
pages: [],
},
};
export function consoleReducer(
state = initialState,
action: SessionActionTypes
): ConsoleState {
switch (action.type) {
case SESSION_RESPONSE:
return {
...state,
session: action.message,
};
default:
return state;
}
}

View File

@@ -0,0 +1,20 @@
// This file is part of MinIO Console Server
// Copyright (c) 2020 MinIO, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
export interface ISessionResponse {
status: string;
pages: string[];
}

View File

@@ -30,7 +30,7 @@ const LoginCallback: FC<RouteComponentProps> = ({ location }) => {
// store the jwt token
storage.setItem("token", res.sessionId);
// We push to history the new URL.
window.location.href = "/dashboard";
window.location.href = "/";
}
})
.catch((res: any) => {

View File

@@ -165,8 +165,9 @@ class Login extends React.Component<ILoginProps, ILoginState> {
.then(() => {
// We set the state in redux
this.props.userLoggedIn(true);
// We push to history the new URL.
history.push("/dashboard");
// There is a browser cache issue if we change the policy associated to an account and then logout and history.push("/") after login
// therefore after login we need to use window.location redirect
window.location.href = "/";
})
.catch(err => {
this.setState({ error: `${err}` });

View File

@@ -23,22 +23,21 @@ import Container from "@material-ui/core/Container";
import Copyright from "../common/Copyright";
import history from "../history";
const useStyles = makeStyles(theme => ({
const useStyles = makeStyles((theme) => ({
"@global": {
body: {
backgroundColor: theme.palette.common.white
}
backgroundColor: theme.palette.common.white,
},
},
paper: {
marginTop: theme.spacing(8),
display: "flex",
flexDirection: "column",
alignItems: "center"
}
alignItems: "center",
},
}));
const NotFound: React.FC = () => {
const classes = useStyles();
console.log(history);
return (
<Container component="main">
<CssBaseline />

View File

@@ -20,12 +20,14 @@ import { systemReducer } from "./reducer";
import { traceReducer } from "./screens/Console/Trace/reducers";
import { logReducer } from "./screens/Console/Logs/reducers";
import { watchReducer } from "./screens/Console/Watch/reducers";
import { consoleReducer } from "./screens/Console/reducer";
const globalReducer = combineReducers({
system: systemReducer,
trace: traceReducer,
logs: logReducer,
watch: watchReducer,
console: consoleReducer,
});
declare global {

View File

@@ -200,10 +200,19 @@ func newMcsCredentials(accessKey, secretKey, location string) (*credentials.Cred
}
}
// GetClaimsFromJWT decrypt and returns the claims associated to a provided jwt
func GetClaimsFromJWT(jwt string) (*auth.DecryptedClaims, error) {
claims, err := auth.JWTAuthenticate(jwt)
if err != nil {
return nil, err
}
return claims, nil
}
// getMcsCredentialsFromJWT returns the *minioCredentials.Credentials associated to the
// provided jwt, this is useful for running the Expire() or IsExpired() operations
func getMcsCredentialsFromJWT(jwt string) (*credentials.Credentials, error) {
claims, err := auth.JWTAuthenticate(jwt)
claims, err := GetClaimsFromJWT(jwt)
if err != nil {
return nil, err
}

View File

@@ -1975,6 +1975,12 @@ func init() {
"sessionResponse": {
"type": "object",
"properties": {
"pages": {
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"type": "string",
"enum": [
@@ -4084,6 +4090,12 @@ func init() {
"sessionResponse": {
"type": "object",
"properties": {
"pages": {
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"type": "string",
"enum": [

View File

@@ -24,6 +24,7 @@ import (
"github.com/go-openapi/runtime/middleware"
"github.com/go-openapi/swag"
"github.com/minio/mcs/models"
"github.com/minio/mcs/pkg/acl"
"github.com/minio/mcs/pkg/auth"
"github.com/minio/mcs/pkg/auth/idp/oauth2"
"github.com/minio/mcs/pkg/auth/utils"
@@ -62,8 +63,9 @@ func registerLoginHandlers(api *operations.McsAPI) {
})
}
// login performs a check of minioCredentials against MinIO
func login(credentials MCSCredentials) (*string, error) {
// login performs a check of minioCredentials against MinIO, generates some claims and returns the jwt
// for subsequent authentication
func login(credentials MCSCredentials, actions []string) (*string, error) {
// try to obtain minioCredentials,
tokens, err := credentials.Get()
if err != nil {
@@ -71,7 +73,7 @@ func login(credentials MCSCredentials) (*string, error) {
return nil, errInvalidCredentials
}
// if we made it here, the minioCredentials work, generate a jwt with claims
jwt, err := auth.NewJWTWithClaimsForClient(&tokens, getMinIOServer())
jwt, err := auth.NewJWTWithClaimsForClient(&tokens, actions, getMinIOServer())
if err != nil {
log.Println("error authenticating user", err)
return nil, errInvalidCredentials
@@ -95,6 +97,7 @@ func getConfiguredRegionForLogin(client MinioAdmin) (string, error) {
// getLoginResponse performs login() and serializes it to the handler's output
func getLoginResponse(lr *models.LoginRequest) (*models.LoginResponse, error) {
ctx := context.Background()
mAdmin, err := newSuperMAdminClient()
if err != nil {
log.Println("error creating Madmin Client:", err)
@@ -113,7 +116,22 @@ func getLoginResponse(lr *models.LoginRequest) (*models.LoginResponse, error) {
return nil, errInvalidCredentials
}
credentials := mcsCredentials{minioCredentials: creds}
sessionID, err := login(credentials)
// obtain the current policy assigned to this user
// necessary for generating the list of allowed endpoints
userInfo, err := adminClient.getUserInfo(ctx, *lr.AccessKey)
if err != nil {
log.Println("error login:", err)
return nil, errInvalidCredentials
}
policy, err := adminClient.getPolicy(ctx, userInfo.PolicyName)
if err != nil {
log.Println("error login:", err)
return nil, errInvalidCredentials
}
actions := acl.GetActionsStringFromPolicy(policy)
sessionID, err := login(credentials, actions)
if err != nil {
return nil, err
}
@@ -201,10 +219,18 @@ func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.Logi
}
}()
// assign the "mcsAdmin" policy to this user
if err := setPolicy(ctx, adminClient, oauth2.GetIDPPolicyForUser(), accessKey, models.PolicyEntityUser); err != nil {
policyName := oauth2.GetIDPPolicyForUser()
if err := setPolicy(ctx, adminClient, policyName, accessKey, models.PolicyEntityUser); err != nil {
log.Println("error setting policy:", err)
return nil, errorGeneric
}
// obtain the current policy details, necessary for generating the list of allowed endpoints
policy, err := adminClient.getPolicy(ctx, policyName)
if err != nil {
log.Println("error reading policy:", err)
return nil, errorGeneric
}
actions := acl.GetActionsStringFromPolicy(policy)
// User was created correctly, create a new session/JWT
creds, err := newMcsCredentials(accessKey, secretKey, location)
if err != nil {
@@ -212,7 +238,7 @@ func getLoginOauth2AuthResponse(lr *models.LoginOauth2AuthRequest) (*models.Logi
return nil, errorGeneric
}
credentials := mcsCredentials{minioCredentials: creds}
jwt, err := login(credentials)
jwt, err := login(credentials, actions)
if err != nil {
return nil, err
}

View File

@@ -52,7 +52,7 @@ func TestLogin(t *testing.T) {
SignerType: 0,
}, nil
}
jwt, err := login(mcsCredentials)
jwt, err := login(mcsCredentials, []string{""})
funcAssert.NotEmpty(jwt, "JWT was returned empty")
funcAssert.Nil(err, "error creating a session")
@@ -60,7 +60,7 @@ func TestLogin(t *testing.T) {
mcsCredentialsGetMock = func() (credentials.Value, error) {
return credentials.Value{}, errors.New("")
}
_, err = login(mcsCredentials)
_, err = login(mcsCredentials, []string{""})
funcAssert.NotNil(err, "not error returned creating a session")
}

View File

@@ -17,26 +17,45 @@
package restapi
import (
"errors"
"log"
"github.com/go-openapi/runtime/middleware"
"github.com/go-openapi/swag"
"github.com/minio/mcs/models"
"github.com/minio/mcs/pkg/acl"
"github.com/minio/mcs/restapi/operations"
"github.com/minio/mcs/restapi/operations/user_api"
)
var (
errorGenericInvalidSession = errors.New("invalid session")
)
func registerSessionHandlers(api *operations.McsAPI) {
// session check
api.UserAPISessionCheckHandler = user_api.SessionCheckHandlerFunc(func(params user_api.SessionCheckParams, principal *models.Principal) middleware.Responder {
sessionResp := getSessionResponse()
sessionID := string(*principal)
sessionResp, err := getSessionResponse(sessionID)
if err != nil {
return user_api.NewSessionCheckDefault(401).WithPayload(&models.Error{Code: 401, Message: swag.String(err.Error())})
}
return user_api.NewSessionCheckOK().WithPayload(sessionResp)
})
}
// getSessionResponse returns only if the session is valid
func getSessionResponse() *models.SessionResponse {
// getSessionResponse parse the jwt of the current session and returns a list of allowed actions to render in the UI
func getSessionResponse(sessionID string) (*models.SessionResponse, error) {
// serialize output
claims, err := GetClaimsFromJWT(sessionID)
if err != nil {
log.Println("error getting claims from JWT", err)
return nil, errorGenericInvalidSession
}
sessionResp := &models.SessionResponse{
Pages: acl.GetAuthorizedEndpoints(claims.Actions),
Status: models.SessionResponseStatusOk,
}
return sessionResp
return sessionResp, nil
}

View File

@@ -1313,6 +1313,10 @@ definitions:
sessionResponse:
type: object
properties:
pages:
type: array
items:
type: string
status:
type: string
enum: [ok]