├── .dockerignore ├── publish └── python │ ├── MANIFEST.in │ ├── setup.cfg │ ├── setup.py │ └── README.rst ├── data ├── deployment.png ├── logo │ └── charon.png ├── test-selfsigned.key └── test-selfsigned.crt ├── cmd ├── charond │ ├── doc.go │ ├── service.go │ ├── main.go │ └── config.go ├── charong │ └── main.go └── charonctl │ ├── service.go │ └── config.go ├── scripts ├── docker-healthcheck.sh ├── generate.sh └── docker-entrypoint.sh ├── .circleci └── scripts │ ├── get_tool.sh │ ├── install_protoc.sh │ ├── test.sh │ └── generate.sh ├── internal ├── charonctl │ ├── error.go │ ├── console.go │ ├── console_obtain_refresh_token.go │ └── console_register_user.go ├── session │ ├── actor.go │ ├── sessionmock │ │ └── actor_provider.go │ ├── session.go │ ├── session_test.go │ └── actor_provider.go ├── mapping │ ├── common.go │ ├── group.go │ ├── user.go │ └── refresh_token.go ├── charond │ ├── error.go │ ├── helpers.go │ ├── handler.go │ ├── health.go │ ├── health_test.go │ ├── handler_logout.go │ ├── suite_postgres_test.go │ ├── helpers_test.go │ ├── handler_register_permissions.go │ ├── handler_get_permission.go │ ├── handler_is_authenticated.go │ ├── handler_belongs_to.go │ ├── handler_is_granted.go │ ├── handler_list_user_groups.go │ ├── handler_list_user_permissions.go │ ├── handler_logout_test.go │ ├── handler_list_groups.go │ ├── handler_get_group.go │ ├── handler_set_user_groups.go │ ├── handler_list_permissions.go │ ├── suite_test.go │ ├── handler_list_group_permissions.go │ ├── handler_create_group.go │ ├── database.go │ ├── handler_delete_group.go │ ├── handler_list_refresh_tokens.go │ ├── handler_set_user_permissions.go │ ├── handler_actor.go │ ├── handler_set_group_permissions.go │ ├── handler_modify_group.go │ ├── daemon_test.go │ ├── handler_get_user.go │ ├── handler_login.go │ ├── service.go │ ├── handler_create_refresh_token.go │ ├── handler_delete_user.go │ ├── handler_revoke_refresh_token.go │ ├── handler_list_users.go │ └── handler_test.go ├── model │ ├── suite_test.go │ ├── group_permissions.go │ ├── modelmock │ │ ├── composition_writer.go │ │ ├── group_permissions_provider.go │ │ ├── user_permissions_provider.go │ │ ├── permission_registry.go │ │ ├── rows.go │ │ └── user_groups_provider.go │ ├── helpers.go │ ├── user_permissions.go │ ├── suite_postgres_test.go │ ├── group_permissions_test.go │ ├── user_permissions_test.go │ ├── refresh_token.go │ ├── group_test.go │ ├── user_groups.go │ └── permission_test.go ├── refreshtoken │ └── refresh_token.go ├── service │ ├── servicemock │ │ └── user_finder.go │ ├── logger │ │ └── logger.go │ └── user_finder.go ├── password │ ├── passwordmock │ │ └── hasher.go │ ├── password.go │ └── password_test.go └── grpcerr │ ├── example_test.go │ └── grpc.go ├── pb └── rpc │ └── charond │ ├── v1 │ ├── common.proto │ ├── permission.proto │ ├── auth.proto │ ├── refresh_token.proto │ ├── group.proto │ └── common.pb.go │ └── v1mock │ ├── is_login_request__strategy.go │ ├── permission_manager_server.go │ ├── refresh_token_manager_server.go │ ├── permission_manager_client.go │ └── refresh_token_manager_client.go ├── .gitignore ├── .codeclimate.yml ├── Dockerfile ├── docker-compose.yml ├── LICENSE ├── Makefile ├── README.md ├── pkg └── securitycontext │ ├── security_context.go │ └── security_context_test.go ├── example └── client │ └── main.go ├── Gopkg.toml └── presentation.slide /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !bin 3 | !scripts -------------------------------------------------------------------------------- /publish/python/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include VERSION.txt 2 | include README.md -------------------------------------------------------------------------------- /publish/python/setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file=README.txt -------------------------------------------------------------------------------- /data/deployment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piotrkowalczuk/charon/HEAD/data/deployment.png -------------------------------------------------------------------------------- /data/logo/charon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piotrkowalczuk/charon/HEAD/data/logo/charon.png -------------------------------------------------------------------------------- /cmd/charond/doc.go: -------------------------------------------------------------------------------- 1 | // Package main is server implementation of Charon auth service. 2 | package main 3 | -------------------------------------------------------------------------------- /scripts/docker-healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | : ${CHAROND_PORT:=8080} 5 | curl -f http://localhost:$((CHAROND_PORT+1))/healthz || exit 1 -------------------------------------------------------------------------------- /.circleci/scripts/get_tool.sh: -------------------------------------------------------------------------------- 1 | rm -rf tmp/tools/$1 2 | mkdir -p tmp/tools/$1 3 | cd tmp/tools/$1 4 | GO111MODULE=on go mod init 5 | GO111MODULE=on go get $1@$2 6 | GO111MODULE=on go install $1 -------------------------------------------------------------------------------- /internal/charonctl/error.go: -------------------------------------------------------------------------------- 1 | package charonctl 2 | 3 | type Error struct { 4 | Msg string 5 | Err error 6 | } 7 | 8 | // Error implements error interface. 9 | func (e *Error) Error() string { 10 | return e.Msg 11 | } 12 | -------------------------------------------------------------------------------- /internal/session/actor.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "github.com/piotrkowalczuk/charon" 5 | "github.com/piotrkowalczuk/charon/internal/model" 6 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 7 | ) 8 | 9 | type Actor struct { 10 | User *model.UserEntity 11 | Session *mnemosynerpc.Session 12 | Permissions charon.Permissions 13 | IsLocal bool 14 | } 15 | -------------------------------------------------------------------------------- /pb/rpc/charond/v1/common.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package charon.rpc.charond.v1; 4 | 5 | option go_package = "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1;charond"; 6 | option java_multiple_files = true; 7 | option java_package = "com.github.charon.rpc.charond.v1"; 8 | 9 | // Order represents single field within OrderBy clause. 10 | message Order { 11 | string name = 1; 12 | bool descending = 2; 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vagrant 2 | .idea 3 | .glide 4 | tmp 5 | example/client/client 6 | vendor 7 | _vendor-* 8 | run.sh 9 | coverage.txt 10 | *.out 11 | bin 12 | 13 | publish/VERSION.txt 14 | 15 | *.xml 16 | *.test 17 | *.zip 18 | 19 | // Python 20 | venv 21 | charon.iml 22 | charon_client.egg-info 23 | *.pyc 24 | publish/python/github* 25 | publish/python/charon_client* 26 | publish/python/charon-client* 27 | publish/python/dist 28 | 29 | // Java 30 | publish/java -------------------------------------------------------------------------------- /cmd/charond/service.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | 7 | "go.uber.org/zap" 8 | ) 9 | 10 | func initListener(logger *zap.Logger, host string, port int) net.Listener { 11 | on := host + ":" + strconv.FormatInt(int64(port), 10) 12 | listener, err := net.Listen("tcp", on) 13 | if err != nil { 14 | logger.Fatal("listener initialization failure", zap.Error(err), zap.String("host", host), zap.Int("port", port)) 15 | } 16 | return listener 17 | } 18 | -------------------------------------------------------------------------------- /internal/mapping/common.go: -------------------------------------------------------------------------------- 1 | package mapping 2 | 3 | import ( 4 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 5 | "github.com/piotrkowalczuk/charon/internal/model" 6 | ) 7 | 8 | func OrderBy(in []*charonrpc.Order) []model.RowOrder { 9 | out := make([]model.RowOrder, 0, len(in)) 10 | for _, i := range in { 11 | out = append(out, model.RowOrder{ 12 | Name: i.GetName(), 13 | Descending: i.GetDescending(), 14 | }) 15 | } 16 | return out 17 | } 18 | -------------------------------------------------------------------------------- /internal/charond/error.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 5 | "google.golang.org/grpc/codes" 6 | "google.golang.org/grpc/status" 7 | ) 8 | 9 | func handleMnemosyneError(err error) error { 10 | if sts, ok := status.FromError(err); ok && sts.Code() == codes.NotFound { 11 | return grpcerr.E(codes.Unauthenticated, "session not found") 12 | } 13 | 14 | return grpcerr.E(codes.Internal, "session fetch failure", err) 15 | } 16 | -------------------------------------------------------------------------------- /.circleci/scripts/install_protoc.sh: -------------------------------------------------------------------------------- 1 | echo "protoc installation in ${PWD}/tmp/bin" 2 | 3 | curl -L https://github.com/google/protobuf/releases/download/v3.7.1/protoc-3.7.1-linux-x86_64.zip > protoc.zip 4 | 5 | rm -rf ./tmp/protoc ./tmp/pb/google 6 | mkdir -p ./tmp/protoc ./tmp/bin ./tmp/pb/google 7 | 8 | unzip protoc.zip -d ./tmp/protoc 9 | 10 | mv -f ./tmp/protoc/bin/protoc ./tmp/bin/protoc 11 | mv -f ./tmp/protoc/include/google ./tmp/pb 12 | 13 | rm -rf ./tmp/protoc 14 | 15 | ./tmp/bin/protoc --version -------------------------------------------------------------------------------- /pb/rpc/charond/v1mock/is_login_request__strategy.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package charondmock 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // isLoginRequest_Strategy is an autogenerated mock type for the isLoginRequest_Strategy type 8 | type isLoginRequest_Strategy struct { 9 | mock.Mock 10 | } 11 | 12 | // isLoginRequest_Strategy provides a mock function with given fields: 13 | func (_m *isLoginRequest_Strategy) isLoginRequest_Strategy() { 14 | _m.Called() 15 | } 16 | -------------------------------------------------------------------------------- /.circleci/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | : ${TEST_RESULTS:=.} 4 | 5 | set -e 6 | 7 | 8 | gotestsum --junitfile results.xml -- -p=1 -count=1 -race -coverprofile=cover-source.out -covermode=atomic -v ./... 9 | cat cover-source.out | grep -v '.pb.go' > cover-step1.out 10 | cat cover-step1.out | grep -v 'mock' > cover-step2.out 11 | cat cover-step2.out | grep -v '/pb/' > cover-step3.out 12 | cat cover-step3.out | grep -v '/example/' > cover-step4.out 13 | cat cover-step4.out | grep -v '.pqt.go' > cover.out 14 | 15 | -------------------------------------------------------------------------------- /internal/model/suite_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "testing" 7 | 8 | _ "github.com/lib/pq" 9 | ) 10 | 11 | var ( 12 | testPostgresAddress string 13 | ) 14 | 15 | func TestMain(m *testing.M) { 16 | flag.StringVar(&testPostgresAddress, "postgres.address", getStringEnvOr("CHAROND_POSTGRES_ADDRESS", "postgres://localhost/test?sslmode=disable"), "") 17 | flag.Parse() 18 | 19 | os.Exit(m.Run()) 20 | } 21 | 22 | func getStringEnvOr(env, or string) string { 23 | if v := os.Getenv(env); v != "" { 24 | return v 25 | } 26 | return or 27 | } 28 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | plugins: 3 | golint: 4 | enabled: true 5 | gofmt: 6 | enabled: true 7 | govet: 8 | enabled: true 9 | checks: 10 | complex-logic: 11 | enabled: false 12 | method-complexity: 13 | enabled: false 14 | file-lines: 15 | enabled: false 16 | method-lines: 17 | enabled: false 18 | return-statements: 19 | enabled: false 20 | exclude_patterns: 21 | - "**/*_test.go" 22 | - "**/*_pb.py" 23 | - "**/*_pb2.py" 24 | - "**/*.pb.go" 25 | - "**/*.pqt.go" 26 | - "**/mock_*.go" 27 | - "pb/" 28 | - "scripts/" 29 | - "vendor/**/*" -------------------------------------------------------------------------------- /scripts/generate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | SERVICE=charon 4 | 5 | cd ./internal/model && charong && cd - 6 | goimports -w ./internal/model 7 | mockery -case=underscore -dir=./internal/model -all -output=./internal/model/modelmock -outpkg=modelmock 8 | mockery -case=underscore -dir=./internal/session -all -output=./internal/session/sessionmock -outpkg=sessionmock 9 | mockery -case=underscore -dir=./internal/password -all -output=./internal/password/passwordmock -outpkg=passwordmock 10 | mockery -case=underscore -dir=./internal/service -all -output=./internal/service/servicemock -outpkg=servicemock 11 | mockery -case=underscore -dir=./${SERVICE}rpc -all -output=./${SERVICE}test -outpkg=${SERVICE}test 12 | goimports -w ./internal/model 13 | 14 | 15 | -------------------------------------------------------------------------------- /internal/charond/helpers.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "github.com/golang/protobuf/ptypes/empty" 5 | "github.com/piotrkowalczuk/ntypes" 6 | ) 7 | 8 | func untouched(given, created, removed int64) int64 { 9 | switch { 10 | case given < 0: 11 | return -1 12 | case given == 0: 13 | return -2 14 | case given < created: 15 | return 0 16 | default: 17 | return given - created 18 | } 19 | } 20 | 21 | func none() *empty.Empty { 22 | return &empty.Empty{} 23 | } 24 | 25 | func allocNilString(s *ntypes.String) ntypes.String { 26 | if s == nil { 27 | return ntypes.String{} 28 | } 29 | return *s 30 | } 31 | 32 | func allocNilBool(b *ntypes.Bool) ntypes.Bool { 33 | if b == nil { 34 | return ntypes.Bool{} 35 | } 36 | return *b 37 | } 38 | -------------------------------------------------------------------------------- /internal/model/group_permissions.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | // GroupPermissionsProvider ... 9 | type GroupPermissionsProvider interface { 10 | Insert(context.Context, *GroupPermissionsEntity) (*GroupPermissionsEntity, error) 11 | } 12 | 13 | // GroupPermissionsRepository extends GroupPermissionsRepositoryBase 14 | type GroupPermissionsRepository struct { 15 | GroupPermissionsRepositoryBase 16 | } 17 | 18 | // NewGroupPermissionsRepository ... 19 | func NewGroupPermissionsRepository(dbPool *sql.DB) GroupPermissionsProvider { 20 | return &GroupPermissionsRepository{ 21 | GroupPermissionsRepositoryBase{ 22 | DB: dbPool, 23 | Table: TableGroupPermissions, 24 | Columns: TableGroupPermissionsColumns, 25 | }, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/charond/handler.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "github.com/piotrkowalczuk/charon/internal/session" 5 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | type handler struct { 10 | session.ActorProvider 11 | 12 | opts DaemonOpts 13 | logger *zap.Logger 14 | repository repositories 15 | session mnemosynerpc.SessionManagerClient 16 | } 17 | 18 | func newHandler(rs *rpcServer) *handler { 19 | h := &handler{ 20 | opts: rs.opts, 21 | session: rs.session, 22 | repository: rs.repository, 23 | logger: rs.logger, 24 | ActorProvider: &session.MnemosyneActorProvider{ 25 | Client: rs.session, 26 | UserProvider: rs.repository.user, 27 | PermissionProvider: rs.repository.permission, 28 | }, 29 | } 30 | 31 | return h 32 | } 33 | -------------------------------------------------------------------------------- /internal/charond/health.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "net/http" 7 | "time" 8 | 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type healthHandler struct { 13 | logger *zap.Logger 14 | postgres *sql.DB 15 | } 16 | 17 | func (hh *healthHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 18 | if hh.postgres != nil { 19 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 20 | defer cancel() 21 | 22 | if err := hh.postgres.PingContext(ctx); err != nil { 23 | hh.logger.Debug("health check failure due to postgres connection") 24 | http.Error(rw, "postgres ping failure", http.StatusServiceUnavailable) 25 | return 26 | } 27 | } 28 | 29 | hh.logger.Debug("successful health check") 30 | rw.WriteHeader(http.StatusOK) 31 | rw.Write([]byte("1")) 32 | } 33 | -------------------------------------------------------------------------------- /internal/charond/health_test.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "go.uber.org/zap" 9 | ) 10 | 11 | func TestHealthHandler_ServeHTTP(t *testing.T) { 12 | l := zap.L() 13 | s := postgresSuite{ 14 | logger: l, 15 | } 16 | s.setup(t) 17 | 18 | h := healthHandler{ 19 | postgres: s.db, 20 | logger: l, 21 | } 22 | 23 | rw := httptest.NewRecorder() 24 | r := &http.Request{} 25 | h.ServeHTTP(rw, r) 26 | if rw.Code != http.StatusOK { 27 | t.Errorf("wrong status code, expected %d but got %d", http.StatusOK, rw.Code) 28 | } 29 | 30 | s.teardown(t) 31 | 32 | rw = httptest.NewRecorder() 33 | h.ServeHTTP(rw, r) 34 | if rw.Code != http.StatusServiceUnavailable { 35 | t.Errorf("wrong status code, expected %d but got %d", http.StatusServiceUnavailable, rw.Code) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /internal/model/modelmock/composition_writer.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package modelmock 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | import model "github.com/piotrkowalczuk/charon/internal/model" 7 | 8 | // CompositionWriter is an autogenerated mock type for the CompositionWriter type 9 | type CompositionWriter struct { 10 | mock.Mock 11 | } 12 | 13 | // WriteComposition provides a mock function with given fields: _a0, _a1, _a2 14 | func (_m *CompositionWriter) WriteComposition(_a0 string, _a1 *model.Composer, _a2 *model.CompositionOpts) error { 15 | ret := _m.Called(_a0, _a1, _a2) 16 | 17 | var r0 error 18 | if rf, ok := ret.Get(0).(func(string, *model.Composer, *model.CompositionOpts) error); ok { 19 | r0 = rf(_a0, _a1, _a2) 20 | } else { 21 | r0 = ret.Error(0) 22 | } 23 | 24 | return r0 25 | } 26 | -------------------------------------------------------------------------------- /internal/charond/handler_logout.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/golang/protobuf/ptypes/empty" 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 9 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 10 | 11 | "google.golang.org/grpc/codes" 12 | ) 13 | 14 | type logoutHandler struct { 15 | *handler 16 | } 17 | 18 | func (lh *logoutHandler) Logout(ctx context.Context, r *charonrpc.LogoutRequest) (*empty.Empty, error) { 19 | if len(r.AccessToken) == 0 { 20 | return nil, grpcerr.E(codes.InvalidArgument, "empty session id, logout aborted") 21 | } 22 | 23 | _, err := lh.session.Abandon(ctx, &mnemosynerpc.AbandonRequest{ 24 | AccessToken: r.AccessToken, 25 | }) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return &empty.Empty{}, nil 31 | } 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | MAINTAINER Piotr Kowalczuk 3 | 4 | ARG BUILD_DATE 5 | ARG VCS_REF 6 | 7 | LABEL org.label-schema.build-date=$BUILD_DATE \ 8 | org.label-schema.docker.dockerfile="Dockerfile" \ 9 | org.label-schema.license="ASL" \ 10 | org.label-schema.name="charon" \ 11 | org.label-schema.url="https://github.com/piotrkowalczuk/charon" \ 12 | org.label-schema.vcs-ref=$VCS_REF \ 13 | org.label-schema.vcs-type="git" \ 14 | org.label-schema.vcs-url="https://github.com/piotrkowalczuk/charon" 15 | 16 | COPY ./bin /usr/local/bin/ 17 | COPY ./scripts/docker-entrypoint.sh / 18 | COPY ./scripts/docker-healthcheck.sh / 19 | 20 | RUN apk --no-cache add curl 21 | 22 | VOLUME /data 23 | EXPOSE 8080 8081 24 | 25 | HEALTHCHECK --interval=1m30s --timeout=3s CMD ["/docker-healthcheck.sh"] 26 | ENTRYPOINT ["/docker-entrypoint.sh"] 27 | CMD ["charond"] -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | postgres: 5 | image: postgres:9.5 6 | container_name: postgres 7 | ports: 8 | - 5434:5432 9 | volumes: 10 | - postgres-data:/var/lib/postgresql/data 11 | mnemosyned: 12 | image: piotrkowalczuk/mnemosyne:v0.8.0 13 | container_name: mnemosyned 14 | environment: 15 | MNEMOSYNED_POSTGRES_SCHEMA: mnemosyne 16 | links: 17 | - postgres 18 | ports: 19 | - 10000:8080 20 | - 10001:8081 21 | charond: 22 | image: charon 23 | container_name: charond 24 | environment: 25 | MNEMOSYNED_POSTGRES_SCHEMA: mnemosyne 26 | links: 27 | - postgres 28 | ports: 29 | - 10010:8080 30 | - 10011:8081 31 | depends_on: 32 | - mnemosyned 33 | volumes: 34 | postgres-data: 35 | external: false 36 | prometheus-data: 37 | external: false -------------------------------------------------------------------------------- /internal/refreshtoken/refresh_token.go: -------------------------------------------------------------------------------- 1 | package refreshtoken 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "io" 7 | 8 | "golang.org/x/crypto/sha3" 9 | ) 10 | 11 | // Random generate refresh token with given key and generated hash of length 16. 12 | func Random() (string, error) { 13 | buf, err := generateRandomBytes(64) 14 | if err != nil { 15 | return "", err 16 | } 17 | 18 | // A hash needs to be 64 bytes long to have 256-bit collision resistance. 19 | hash := make([]byte, 32) 20 | // Compute a 64-byte hash of buf and put it in h. 21 | sha3.ShakeSum128(hash, buf) 22 | hash2 := make([]byte, hex.EncodedLen(len(hash))) 23 | hex.Encode(hash2, hash) 24 | return string(hash2), nil 25 | } 26 | 27 | func generateRandomBytes(length int) ([]byte, error) { 28 | k := make([]byte, length) 29 | if _, err := io.ReadFull(rand.Reader, k); err != nil { 30 | return nil, err 31 | } 32 | return k, nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/model/helpers.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | func untouched(given, created, removed int64) int64 { 10 | switch { 11 | case given < 0: 12 | return -1 13 | case given == 0: 14 | return -2 15 | case given < created: 16 | return 0 17 | default: 18 | return given - created 19 | } 20 | } 21 | 22 | func initPostgres(address string, test bool, logger *zap.Logger) (*sql.DB, error) { 23 | postgres, err := sql.Open("postgres", address) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | if test { 29 | if err = teardownDatabase(postgres); err != nil { 30 | return nil, err 31 | } 32 | logger.Info("database has been cleared upfront") 33 | } 34 | err = setupDatabase(postgres) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | logger.Info("postgres connection has been established", zap.String("address", address)) 40 | 41 | return postgres, nil 42 | } 43 | -------------------------------------------------------------------------------- /publish/python/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from subprocess import check_output 3 | 4 | with open('../VERSION.txt', 'r') as content_file: 5 | version = content_file.read() 6 | 7 | setup( 8 | name='charon-client', 9 | version=version[1:], 10 | description='charon service grpc client library', 11 | url='http://github.com/piotrkowalczuk/charon', 12 | author='Piotr Kowalczuk', 13 | author_email='p.kowalczuk.priv@gmail.com', 14 | license='MIT', 15 | packages=['github.com.piotrkowalczuk.charon.pb.rpc.charond.v1'], 16 | install_requires=[ 17 | 'protobuf', 18 | 'grpcio', 19 | 'protobuf-ntypes', 20 | 'protobuf-qtypes', 21 | ], 22 | zip_safe=False, 23 | keywords=['charon', 'grpc', 'authentication', 'authorization', 'service', 'client'], 24 | download_url='https://github.com/piotrkowalczuk/charon/archive/%s.tar.gz' % version.rstrip(), 25 | ) -------------------------------------------------------------------------------- /internal/service/servicemock/user_finder.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package servicemock 4 | 5 | import context "context" 6 | import mock "github.com/stretchr/testify/mock" 7 | import model "github.com/piotrkowalczuk/charon/internal/model" 8 | 9 | // UserFinder is an autogenerated mock type for the UserFinder type 10 | type UserFinder struct { 11 | mock.Mock 12 | } 13 | 14 | // FindUser provides a mock function with given fields: _a0 15 | func (_m *UserFinder) FindUser(_a0 context.Context) (*model.UserEntity, error) { 16 | ret := _m.Called(_a0) 17 | 18 | var r0 *model.UserEntity 19 | if rf, ok := ret.Get(0).(func(context.Context) *model.UserEntity); ok { 20 | r0 = rf(_a0) 21 | } else { 22 | if ret.Get(0) != nil { 23 | r0 = ret.Get(0).(*model.UserEntity) 24 | } 25 | } 26 | 27 | var r1 error 28 | if rf, ok := ret.Get(1).(func(context.Context) error); ok { 29 | r1 = rf(_a0) 30 | } else { 31 | r1 = ret.Error(1) 32 | } 33 | 34 | return r0, r1 35 | } 36 | -------------------------------------------------------------------------------- /internal/session/sessionmock/actor_provider.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package sessionmock 4 | 5 | import context "context" 6 | import mock "github.com/stretchr/testify/mock" 7 | import session "github.com/piotrkowalczuk/charon/internal/session" 8 | 9 | // ActorProvider is an autogenerated mock type for the ActorProvider type 10 | type ActorProvider struct { 11 | mock.Mock 12 | } 13 | 14 | // Actor provides a mock function with given fields: _a0 15 | func (_m *ActorProvider) Actor(_a0 context.Context) (*session.Actor, error) { 16 | ret := _m.Called(_a0) 17 | 18 | var r0 *session.Actor 19 | if rf, ok := ret.Get(0).(func(context.Context) *session.Actor); ok { 20 | r0 = rf(_a0) 21 | } else { 22 | if ret.Get(0) != nil { 23 | r0 = ret.Get(0).(*session.Actor) 24 | } 25 | } 26 | 27 | var r1 error 28 | if rf, ok := ret.Get(1).(func(context.Context) error); ok { 29 | r1 = rf(_a0) 30 | } else { 31 | r1 = ret.Error(1) 32 | } 33 | 34 | return r0, r1 35 | } 36 | -------------------------------------------------------------------------------- /publish/python/README.rst: -------------------------------------------------------------------------------- 1 | ################# 2 | Charon Client 3 | ################# 4 | 5 | TODO 6 | 7 | ************* 8 | Example 9 | ************* 10 | 11 | :: 12 | 13 | $ pip install charon-client 14 | 15 | 16 | .. code-block:: python 17 | 18 | from github.com.piotrkowalczuk.charon.pb.rpc.charond.v1 import auth_pb2, auth_pb2_grpc 19 | import grpc 20 | 21 | charonChannel = grpc.insecure_channel('ADDRESS') 22 | auth = auth_pb2_grpc.AuthStub(charonChannel) 23 | try: 24 | res = auth.Login(auth_pb2.LoginRequest( 25 | username="USERNAME", 26 | password="PASSWORD", 27 | )) 28 | 29 | print "access token: %s" % res.value 30 | except grpc.RpcError as e: 31 | if e.code() == grpc.StatusCode.UNAUTHENTICATED: 32 | print "login failure, username and/or password do not match" 33 | else: 34 | print "grpc error: %s" % e 35 | except Exception as e: 36 | print "unexpected error: %s" % e 37 | 38 | 39 | -------------------------------------------------------------------------------- /internal/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | ) 8 | 9 | const ( 10 | actorIDPrefix = "charon:user:" 11 | ) 12 | 13 | // ActorID is globally unique identifier that in format "charon:user:". 14 | type ActorID string 15 | 16 | // ActorIDFromInt64 allocate ActorID using given user id. 17 | func ActorIDFromInt64(userID int64) ActorID { 18 | return ActorID(actorIDPrefix + strconv.FormatInt(userID, 10)) 19 | } 20 | 21 | // String implements fmt.Stringer interface. 22 | func (ai ActorID) String() string { 23 | return string(ai) 24 | } 25 | 26 | // UserID returns user id if possible, otherwise an error. 27 | func (ai ActorID) UserID() (int64, error) { 28 | if len(ai) < 13 { 29 | return 0, errors.New("charon: session actor id to short, min length 13 characters") 30 | } 31 | if ai[:12] != actorIDPrefix { 32 | return 0, fmt.Errorf("charon: session actor id wrong prefix expected %s, got %s", actorIDPrefix, ai[:12]) 33 | } 34 | 35 | return strconv.ParseInt(string(ai)[12:], 10, 64) 36 | } 37 | -------------------------------------------------------------------------------- /internal/charond/suite_postgres_test.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | ) 9 | 10 | type postgresSuite struct { 11 | logger *zap.Logger 12 | db *sql.DB 13 | repository repositories 14 | } 15 | 16 | func (ps *postgresSuite) setup(t *testing.T) { 17 | if testing.Short() { 18 | t.Skip("postgres suite ignored in short mode") 19 | } 20 | 21 | var err error 22 | 23 | ps.logger = zap.L() 24 | ps.db, err = initPostgres(testPostgresAddress, true, ps.logger) 25 | if err != nil { 26 | t.Fatalf("postgres connection (%s) error: %s", testPostgresAddress, err.Error()) 27 | } 28 | 29 | ps.repository = newRepositories(ps.db) 30 | } 31 | 32 | func (ps *postgresSuite) teardown(t *testing.T) { 33 | var err error 34 | 35 | if err = teardownDatabase(ps.db); err != nil { 36 | t.Fatalf("postgres suite database teardown error: %s", err.Error()) 37 | } 38 | if err = ps.db.Close(); err != nil { 39 | t.Fatalf("postgres suite teardown database connection error: %s", err.Error()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Piotr Kowalczuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/charond/helpers_test.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import "testing" 4 | 5 | func TestUntouched(t *testing.T) { 6 | data := []struct { 7 | given, created, removed, untouched int64 8 | }{ 9 | { 10 | given: -1000, 11 | created: 2, 12 | removed: 10, 13 | untouched: -1, 14 | }, 15 | { 16 | given: 0, 17 | created: 0, 18 | removed: 0, 19 | untouched: -2, 20 | }, 21 | { 22 | given: 5, 23 | created: 3, 24 | removed: 0, 25 | untouched: 2, 26 | }, 27 | { 28 | given: 10, 29 | created: 0, 30 | removed: 0, 31 | untouched: 10, 32 | }, 33 | { 34 | given: 100, 35 | created: 100, 36 | removed: 100, 37 | untouched: 0, 38 | }, 39 | { 40 | given: 5, 41 | created: 0, 42 | removed: 100, 43 | untouched: 5, 44 | }, 45 | { 46 | given: 5, 47 | created: 6, 48 | removed: 0, 49 | untouched: 0, 50 | }, 51 | } 52 | 53 | for _, d := range data { 54 | u := untouched(d.given, d.created, d.removed) 55 | if u != d.untouched { 56 | t.Errorf("wrong value, expected %d but got %d", d.untouched, u) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/password/passwordmock/hasher.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package passwordmock 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | // Hasher is an autogenerated mock type for the Hasher type 8 | type Hasher struct { 9 | mock.Mock 10 | } 11 | 12 | // Compare provides a mock function with given fields: _a0, _a1 13 | func (_m *Hasher) Compare(_a0 []byte, _a1 []byte) bool { 14 | ret := _m.Called(_a0, _a1) 15 | 16 | var r0 bool 17 | if rf, ok := ret.Get(0).(func([]byte, []byte) bool); ok { 18 | r0 = rf(_a0, _a1) 19 | } else { 20 | r0 = ret.Get(0).(bool) 21 | } 22 | 23 | return r0 24 | } 25 | 26 | // Hash provides a mock function with given fields: _a0 27 | func (_m *Hasher) Hash(_a0 []byte) ([]byte, error) { 28 | ret := _m.Called(_a0) 29 | 30 | var r0 []byte 31 | if rf, ok := ret.Get(0).(func([]byte) []byte); ok { 32 | r0 = rf(_a0) 33 | } else { 34 | if ret.Get(0) != nil { 35 | r0 = ret.Get(0).([]byte) 36 | } 37 | } 38 | 39 | var r1 error 40 | if rf, ok := ret.Get(1).(func([]byte) error); ok { 41 | r1 = rf(_a0) 42 | } else { 43 | r1 = ret.Error(1) 44 | } 45 | 46 | return r0, r1 47 | } 48 | -------------------------------------------------------------------------------- /internal/model/modelmock/group_permissions_provider.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package modelmock 4 | 5 | import context "context" 6 | import mock "github.com/stretchr/testify/mock" 7 | import model "github.com/piotrkowalczuk/charon/internal/model" 8 | 9 | // GroupPermissionsProvider is an autogenerated mock type for the GroupPermissionsProvider type 10 | type GroupPermissionsProvider struct { 11 | mock.Mock 12 | } 13 | 14 | // Insert provides a mock function with given fields: _a0, _a1 15 | func (_m *GroupPermissionsProvider) Insert(_a0 context.Context, _a1 *model.GroupPermissionsEntity) (*model.GroupPermissionsEntity, error) { 16 | ret := _m.Called(_a0, _a1) 17 | 18 | var r0 *model.GroupPermissionsEntity 19 | if rf, ok := ret.Get(0).(func(context.Context, *model.GroupPermissionsEntity) *model.GroupPermissionsEntity); ok { 20 | r0 = rf(_a0, _a1) 21 | } else { 22 | if ret.Get(0) != nil { 23 | r0 = ret.Get(0).(*model.GroupPermissionsEntity) 24 | } 25 | } 26 | 27 | var r1 error 28 | if rf, ok := ret.Get(1).(func(context.Context, *model.GroupPermissionsEntity) error); ok { 29 | r1 = rf(_a0, _a1) 30 | } else { 31 | r1 = ret.Error(1) 32 | } 33 | 34 | return r0, r1 35 | } 36 | -------------------------------------------------------------------------------- /internal/session/session_test.go: -------------------------------------------------------------------------------- 1 | package session_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/piotrkowalczuk/charon/internal/session" 7 | ) 8 | 9 | func TestUserIDFromSessionActorID(t *testing.T) { 10 | success := map[string]int64{ 11 | "charon:user:1": 1, 12 | "charon:user:0": 0, 13 | "charon:user:12312412512512": 12312412512512, 14 | } 15 | 16 | for given, expected := range success { 17 | userID, err := session.ActorID(given).UserID() 18 | if err != nil { 19 | t.Errorf("unexpected error: %s", err.Error()) 20 | } else if userID != expected { 21 | t.Errorf("wrong user id retrieved from session subject id expected %d, got %d", expected, userID) 22 | } 23 | } 24 | 25 | failures := []string{ 26 | "", 27 | "charon", 28 | "charon:", 29 | "charon:user", 30 | "charon:user:", 31 | ":user:1", 32 | "user:1", 33 | ":1", 34 | "1", 35 | "1231251251241241241251251", 36 | "charon:resu:52151235125123", 37 | "charon:u:52151235125123", 38 | "charon:user:1234567890x", 39 | } 40 | 41 | for _, given := range failures { 42 | _, err := session.ActorID(given).UserID() 43 | if err == nil { 44 | t.Errorf("expected error %s", err.Error()) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/charond/handler_register_permissions.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/piotrkowalczuk/charon" 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 9 | "github.com/piotrkowalczuk/charon/internal/model" 10 | "google.golang.org/grpc/codes" 11 | ) 12 | 13 | type registerPermissionsHandler struct { 14 | *handler 15 | registry model.PermissionRegistry 16 | } 17 | 18 | func (rph *registerPermissionsHandler) Register(ctx context.Context, req *charonrpc.RegisterPermissionsRequest) (*charonrpc.RegisterPermissionsResponse, error) { 19 | permissions := charon.NewPermissions(req.Permissions...) 20 | created, untouched, removed, err := rph.registry.Register(ctx, permissions) 21 | if err != nil { 22 | switch err { 23 | case model.ErrEmptySliceOfPermissions, model.ErrEmptySubsystem, model.ErrorInconsistentSubsystem: 24 | return nil, grpcerr.E(codes.InvalidArgument, err) 25 | default: 26 | return nil, grpcerr.E(codes.Internal, "permission registration failure", err) 27 | } 28 | } 29 | 30 | return &charonrpc.RegisterPermissionsResponse{ 31 | Created: created, 32 | Untouched: untouched, 33 | Removed: removed, 34 | }, nil 35 | } 36 | -------------------------------------------------------------------------------- /cmd/charong/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | 10 | "github.com/piotrkowalczuk/pqt/pqtgo/pqtgogen" 11 | 12 | "github.com/piotrkowalczuk/ntypespqt" 13 | "github.com/piotrkowalczuk/pqt/pqtsql" 14 | "github.com/piotrkowalczuk/qtypespqt" 15 | ) 16 | 17 | var ( 18 | schema, output string 19 | ) 20 | 21 | func init() { 22 | flag.StringVar(&schema, "schema", "charon", "") 23 | flag.StringVar(&output, "output", "schema.pqt", "") 24 | } 25 | 26 | func main() { 27 | file, err := openFile(output) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | defer file.Close() 32 | 33 | sch := databaseSchema() 34 | genGo := &pqtgogen.Generator{ 35 | Pkg: "model", 36 | Version: 9.5, 37 | Components: pqtgogen.ComponentAll, 38 | Plugins: []pqtgogen.Plugin{ 39 | &qtypespqt.Plugin{}, 40 | &ntypespqt.Plugin{}, 41 | }, 42 | } 43 | genSQL := &pqtsql.Generator{} 44 | if err := genGo.GenerateTo(sch, file); err != nil { 45 | log.Fatal(err) 46 | } 47 | fmt.Fprint(file, "const SQL = `\n") 48 | if err := genSQL.GenerateTo(sch, file); err != nil { 49 | log.Fatal(err) 50 | } 51 | fmt.Fprint(file, "`") 52 | } 53 | 54 | func openFile(output string) (io.WriteCloser, error) { 55 | return os.OpenFile(output+".go", os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0660) 56 | } 57 | -------------------------------------------------------------------------------- /internal/grpcerr/example_test.go: -------------------------------------------------------------------------------- 1 | package grpcerr_test 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 7 | ) 8 | 9 | func ExampleError() { 10 | // Single error. 11 | e1 := grpcerr.E(grpcerr.Op("Get"), grpcerr.Kind("io"), "network unreachable") 12 | fmt.Println("\nSimple error:") 13 | fmt.Println(e1) 14 | // Nested error. 15 | fmt.Println("\nNested error:") 16 | e2 := grpcerr.E(grpcerr.Op("Read"), grpcerr.Kind("other"), e1) 17 | fmt.Println(e2) 18 | // Output: 19 | // 20 | // Simple error: 21 | // Get: io: network unreachable 22 | // 23 | // Nested error: 24 | // Read: io: 25 | // Get: network unreachable 26 | } 27 | func ExampleMatch() { 28 | err := grpcerr.E("network unreachable") 29 | // Construct an error, one we pretend to have received from a test. 30 | got := grpcerr.E(grpcerr.Op("Get"), grpcerr.Kind("io"), err) 31 | // Now construct a reference error, which might not have all 32 | // the fields of the error from the test. 33 | expect := grpcerr.E(grpcerr.Kind("io"), err) 34 | fmt.Println("Match:", grpcerr.Match(expect, got)) 35 | // Now one that's incorrect - wrong Kind. 36 | got = grpcerr.E(grpcerr.Op("Get"), grpcerr.Kind("permission"), err) 37 | fmt.Println("Mismatch:", grpcerr.Match(expect, got)) 38 | // Output: 39 | // 40 | // Match: true 41 | // Mismatch: false 42 | } 43 | -------------------------------------------------------------------------------- /internal/model/user_permissions.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | // UserPermissionsProvider ... 10 | type UserPermissionsProvider interface { 11 | Insert(context.Context, *UserPermissionsEntity) (*UserPermissionsEntity, error) 12 | DeleteByUserID(context.Context, int64) (int64, error) 13 | } 14 | 15 | // UserPermissionsRepository extends UserPermissionsRepositoryBase 16 | type UserPermissionsRepository struct { 17 | UserPermissionsRepositoryBase 18 | deleteByUserIDQuery string 19 | } 20 | 21 | // NewUserPermissionsRepository ... 22 | func NewUserPermissionsRepository(dbPool *sql.DB) UserPermissionsProvider { 23 | return &UserPermissionsRepository{ 24 | UserPermissionsRepositoryBase: UserPermissionsRepositoryBase{ 25 | DB: dbPool, 26 | Table: TableUserPermissions, 27 | Columns: TableUserPermissionsColumns, 28 | }, 29 | deleteByUserIDQuery: fmt.Sprintf("DELETE FROM %s WHERE %s = $1", TableUserPermissions, TableUserPermissionsColumnUserID), 30 | } 31 | } 32 | 33 | // DeleteByUserID removes all permissions of given user. 34 | func (upr *UserPermissionsRepository) DeleteByUserID(ctx context.Context, id int64) (int64, error) { 35 | res, err := upr.DB.ExecContext(ctx, upr.deleteByUserIDQuery, id) 36 | if err != nil { 37 | return 0, err 38 | } 39 | return res.RowsAffected() 40 | } 41 | -------------------------------------------------------------------------------- /internal/grpcerr/grpc.go: -------------------------------------------------------------------------------- 1 | package grpcerr 2 | 3 | import ( 4 | "context" 5 | 6 | "google.golang.org/grpc" 7 | "google.golang.org/grpc/status" 8 | ) 9 | 10 | func UnaryServerInterceptor() grpc.UnaryServerInterceptor { 11 | return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (res interface{}, err error) { 12 | res, err = handler(ctx, req) 13 | return res, grpcError(err) 14 | } 15 | } 16 | 17 | func StreamServerInterceptor() grpc.StreamServerInterceptor { 18 | return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { 19 | return grpcError(handler(srv, ss)) 20 | } 21 | } 22 | 23 | func grpcError(err error) error { 24 | ert, ok := err.(*Error) 25 | if !ok { 26 | return err 27 | } 28 | 29 | if ert.Msg != "" { 30 | sts, err := status.Newf(ert.Code, "%s: %s", ert.Msg, ert.Err).WithDetails(ert.Details...) 31 | // error is not nil only if code was OK 32 | if err != nil { 33 | return status.Newf(ert.Code, "%s: %s", ert.Msg, ert.Err).Err() 34 | } 35 | 36 | return sts.Err() 37 | } else { 38 | sts, err := status.Newf(ert.Code, "%s", ert.Err).WithDetails(ert.Details...) 39 | // error is not nil only if code was OK 40 | if err != nil { 41 | return status.Newf(ert.Code, "%s", ert.Err).Err() 42 | } 43 | 44 | return sts.Err() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/password/password.go: -------------------------------------------------------------------------------- 1 | package password 2 | 3 | import ( 4 | "errors" 5 | 6 | "golang.org/x/crypto/bcrypt" 7 | ) 8 | 9 | var ( 10 | // ErrBCryptCostOutOfRange can be returned by NewBCryptHasher if provided cost is not between min and max. 11 | ErrBCryptCostOutOfRange = errors.New("password: bcrypt cost out of range") 12 | ) 13 | 14 | // Hasher define set of methods that object needs to implement to be considered as a hasher. 15 | type Hasher interface { 16 | Hash([]byte) ([]byte, error) 17 | Compare([]byte, []byte) bool 18 | } 19 | 20 | // BCryptHasher hasher that use BCrypt algorithm to secure password. 21 | type BCryptHasher struct { 22 | cost int 23 | } 24 | 25 | // NewBCryptHasher allocates new NewBCryptHasher. 26 | // If cost is not between min and max value it returns an error. 27 | func NewBCryptHasher(cost int) (Hasher, error) { 28 | if bcrypt.MinCost > cost || cost > bcrypt.MaxCost { 29 | return nil, ErrBCryptCostOutOfRange 30 | } 31 | 32 | return &BCryptHasher{cost: cost}, nil 33 | } 34 | 35 | // Hash implements Hasher interface. 36 | func (bh BCryptHasher) Hash(plainPassword []byte) ([]byte, error) { 37 | return bcrypt.GenerateFromPassword(plainPassword, bh.cost) 38 | } 39 | 40 | // Compare implements Hasher interface. 41 | func (bh BCryptHasher) Compare(hashedPassword, plainPassword []byte) bool { 42 | err := bcrypt.CompareHashAndPassword(hashedPassword, plainPassword) 43 | return err == nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/mapping/group.go: -------------------------------------------------------------------------------- 1 | package mapping 2 | 3 | import ( 4 | "github.com/golang/protobuf/ptypes" 5 | pbts "github.com/golang/protobuf/ptypes/timestamp" 6 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 7 | "github.com/piotrkowalczuk/charon/internal/model" 8 | ) 9 | 10 | // ReverseGroup maps internal entity struct into protobuf message used by a client. 11 | func ReverseGroup(ent *model.GroupEntity) (*charonrpc.Group, error) { 12 | var ( 13 | err error 14 | createdAt, updatedAt *pbts.Timestamp 15 | ) 16 | 17 | if createdAt, err = ptypes.TimestampProto(ent.CreatedAt); err != nil { 18 | return nil, err 19 | } 20 | if ent.UpdatedAt.Valid { 21 | if updatedAt, err = ptypes.TimestampProto(ent.UpdatedAt.Time); err != nil { 22 | return nil, err 23 | } 24 | } 25 | 26 | return &charonrpc.Group{ 27 | Id: ent.ID, 28 | Name: ent.Name, 29 | Description: ent.Description.Chars, 30 | CreatedAt: createdAt, 31 | CreatedBy: &ent.CreatedBy, 32 | UpdatedAt: updatedAt, 33 | UpdatedBy: &ent.UpdatedBy, 34 | }, nil 35 | } 36 | 37 | // ReverseGroups does same thing like ReverseGroup but operate on slices. 38 | func ReverseGroups(in []*model.GroupEntity) ([]*charonrpc.Group, error) { 39 | res := make([]*charonrpc.Group, 0, len(in)) 40 | for _, ent := range in { 41 | msg, err := ReverseGroup(ent) 42 | if err != nil { 43 | return nil, err 44 | } 45 | res = append(res, msg) 46 | } 47 | 48 | return res, nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/charonctl/console.go: -------------------------------------------------------------------------------- 1 | package charonctl 2 | 3 | import ( 4 | "context" 5 | 6 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 7 | "github.com/piotrkowalczuk/mnemosyne" 8 | "google.golang.org/grpc" 9 | "google.golang.org/grpc/metadata" 10 | ) 11 | 12 | type ConsoleOpts struct { 13 | Conn *grpc.ClientConn 14 | Username string 15 | Password string 16 | } 17 | 18 | type Console struct { 19 | Ctx context.Context 20 | consoleRegisterUser 21 | consoleObtainRefreshToken 22 | } 23 | 24 | func NewConsole(opts ConsoleOpts) (*Console, error) { 25 | auth := charonrpc.NewAuthClient(opts.Conn) 26 | user := charonrpc.NewUserManagerClient(opts.Conn) 27 | refreshToken := charonrpc.NewRefreshTokenManagerClient(opts.Conn) 28 | 29 | c := &Console{ 30 | Ctx: context.Background(), 31 | consoleObtainRefreshToken: consoleObtainRefreshToken{ 32 | refreshToken: refreshToken, 33 | }, 34 | consoleRegisterUser: consoleRegisterUser{ 35 | user: user, 36 | }, 37 | } 38 | 39 | ctx := context.Background() 40 | 41 | if opts.Password != "" || opts.Username != "" { 42 | resp, err := auth.Login(context.Background(), &charonrpc.LoginRequest{ 43 | Username: opts.Username, 44 | Password: opts.Password, 45 | }) 46 | if err != nil { 47 | return nil, &Error{ 48 | Msg: "(initial) login failure", 49 | Err: err, 50 | } 51 | } 52 | 53 | c.Ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs(mnemosyne.AccessTokenMetadataKey, resp.Value)) 54 | } 55 | 56 | return c, nil 57 | } 58 | -------------------------------------------------------------------------------- /internal/mapping/user.go: -------------------------------------------------------------------------------- 1 | package mapping 2 | 3 | import ( 4 | "github.com/golang/protobuf/ptypes" 5 | pbts "github.com/golang/protobuf/ptypes/timestamp" 6 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 7 | "github.com/piotrkowalczuk/charon/internal/model" 8 | ) 9 | 10 | func ReverseUser(ent *model.UserEntity) (*charonrpc.User, error) { 11 | var ( 12 | err error 13 | createdAt, updatedAt *pbts.Timestamp 14 | ) 15 | 16 | if !ent.CreatedAt.IsZero() { 17 | if createdAt, err = ptypes.TimestampProto(ent.CreatedAt); err != nil { 18 | return nil, err 19 | } 20 | } 21 | if ent.UpdatedAt.Valid { 22 | if updatedAt, err = ptypes.TimestampProto(ent.UpdatedAt.Time); err != nil { 23 | return nil, err 24 | } 25 | } 26 | 27 | return &charonrpc.User{ 28 | Id: ent.ID, 29 | Username: ent.Username, 30 | FirstName: ent.FirstName, 31 | LastName: ent.LastName, 32 | IsSuperuser: ent.IsSuperuser, 33 | IsActive: ent.IsActive, 34 | IsStaff: ent.IsStaff, 35 | IsConfirmed: ent.IsConfirmed, 36 | CreatedAt: createdAt, 37 | CreatedBy: &ent.CreatedBy, 38 | UpdatedAt: updatedAt, 39 | UpdatedBy: &ent.UpdatedBy, 40 | }, nil 41 | } 42 | 43 | func ReverseUsers(in []*model.UserEntity) ([]*charonrpc.User, error) { 44 | res := make([]*charonrpc.User, 0, len(in)) 45 | for _, ent := range in { 46 | msg, err := ReverseUser(ent) 47 | if err != nil { 48 | return nil, err 49 | } 50 | res = append(res, msg) 51 | } 52 | 53 | return res, nil 54 | } 55 | -------------------------------------------------------------------------------- /.circleci/scripts/generate.sh: -------------------------------------------------------------------------------- 1 | #@IgnoreInspection BashAddShebang 2 | SERVICE="charon" 3 | PROTO_INCLUDE="-I=./tmp/pb -I=./vendor/github.com/piotrkowalczuk -I=${GOPATH}/src" 4 | 5 | : ${PROTOC:="${PWD}/tmp/bin/protoc"} 6 | 7 | protobufs=( 8 | "rpc/${SERVICE}d/v1" 9 | ) 10 | for protobuf in "${protobufs[@]}" 11 | do 12 | case $1 in 13 | lint) 14 | ${PROTOC} ${PROTO_INCLUDE} --lint_out=. ${PWD}/pb/${protobuf}/*.proto 15 | ;; 16 | python) 17 | python -m grpc_tools.protoc ${PROTO_INCLUDE} --python_out=publish/python --grpc_python_out=publish/python ${PWD}/pb/${protobuf}/*.proto 18 | cp publish/python/github.com/piotrkowalczuk/charon/pb/${protobuf}/* publish/python/github/com/piotrkowalczuk/charon/pb/${protobuf}/ 19 | rm -rf publish/python/github.com 20 | ;; 21 | java) 22 | rm -rf ./publish/java 23 | mkdir -p ./publish/java 24 | ${PROTOC} ${PROTO_INCLUDE} --java_out=./publish/java ${PWD}/pb/${protobuf}/*.proto 25 | ;; 26 | golang | go) 27 | ${PROTOC} ${PROTO_INCLUDE} --go_out=plugins=grpc:${GOPATH}/src ${PWD}/pb/${protobuf}/*.proto 28 | mockery -case=underscore -dir=./pb/${protobuf} -all -outpkg=$(basename $(dirname "./pb/${protobuf}mock"))mock -output=./pb/${protobuf}mock 29 | goimports -w ${PWD}/pb 30 | ;; 31 | *) 32 | echo "code generation failure due to unknown language: ${1}" 33 | exit 1 34 | ;; 35 | esac 36 | done -------------------------------------------------------------------------------- /cmd/charonctl/service.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/mnemosyne" 10 | "google.golang.org/grpc" 11 | "google.golang.org/grpc/metadata" 12 | "google.golang.org/grpc/status" 13 | ) 14 | 15 | type client struct { 16 | auth charonrpc.AuthClient 17 | user charonrpc.UserManagerClient 18 | group charonrpc.GroupManagerClient 19 | permission charonrpc.PermissionManagerClient 20 | } 21 | 22 | func initClient(addr string) (c *client, ctx context.Context) { 23 | conn, err := grpc.Dial(addr, grpc.WithInsecure(), grpc.WithUserAgent("charonctl")) 24 | if err != nil { 25 | fmt.Printf("charond connection failure to %s with error: %s\n", addr, status.Convert(err).Message()) 26 | os.Exit(1) 27 | } 28 | 29 | c = &client{ 30 | auth: charonrpc.NewAuthClient(conn), 31 | user: charonrpc.NewUserManagerClient(conn), 32 | group: charonrpc.NewGroupManagerClient(conn), 33 | permission: charonrpc.NewPermissionManagerClient(conn), 34 | } 35 | ctx = context.Background() 36 | 37 | if config.auth.enabled { 38 | resp, err := c.auth.Login(context.Background(), &charonrpc.LoginRequest{ 39 | Username: config.auth.username, 40 | Password: config.auth.password, 41 | }) 42 | if err != nil { 43 | fmt.Printf("(initial) login failure: %s\n", status.Convert(err).Message()) 44 | os.Exit(1) 45 | } 46 | 47 | ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs(mnemosyne.AccessTokenMetadataKey, resp.Value)) 48 | } 49 | 50 | return 51 | } 52 | -------------------------------------------------------------------------------- /internal/model/suite_postgres_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/lib/pq" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type postgresSuite struct { 12 | logger *zap.Logger 13 | db *sql.DB 14 | repository repositories 15 | } 16 | 17 | func (ps *postgresSuite) setup(t *testing.T) { 18 | if testing.Short() { 19 | t.Skip("postgres suite ignored in short mode") 20 | } 21 | 22 | var err error 23 | 24 | ps.logger = zap.L() 25 | ps.db, err = initPostgres(testPostgresAddress, true, ps.logger) 26 | if err != nil { 27 | t.Fatalf("postgres connection (%s) error: %s", testPostgresAddress, err.Error()) 28 | } 29 | 30 | ps.repository = newRepositories(ps.db) 31 | } 32 | 33 | func (ps *postgresSuite) teardown(t *testing.T) { 34 | var err error 35 | 36 | if err = teardownDatabase(ps.db); err != nil { 37 | t.Fatalf("postgres suite database teardown error: %s", err.Error()) 38 | } 39 | if err = ps.db.Close(); err != nil { 40 | t.Fatalf("postgres suite teardown database connection error: %s", err.Error()) 41 | } 42 | } 43 | 44 | func assertf(t *testing.T, is bool, msg string, args ...interface{}) bool { 45 | if !is { 46 | t.Errorf(msg, args...) 47 | } 48 | 49 | return is 50 | } 51 | 52 | func assert(t *testing.T, is bool, msg string) bool { 53 | if !is { 54 | t.Errorf(msg) 55 | } 56 | 57 | return is 58 | } 59 | 60 | func assertfNullTime(t *testing.T, tm pq.NullTime, msg string, args ...interface{}) bool { 61 | if !tm.Valid || tm.Time.IsZero() { 62 | t.Errorf(msg, args...) 63 | return false 64 | } 65 | 66 | return true 67 | } 68 | -------------------------------------------------------------------------------- /pb/rpc/charond/v1/permission.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package charon.rpc.charond.v1; 4 | 5 | option go_package = "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1;charond"; 6 | option java_multiple_files = true; 7 | option java_package = "com.github.charon.rpc.charond.v1"; 8 | 9 | import "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1/common.proto"; 10 | import "qtypes/qtypes.proto"; 11 | import "ntypes/ntypes.proto"; 12 | 13 | service PermissionManager { 14 | rpc Register(RegisterPermissionsRequest) returns (RegisterPermissionsResponse) {}; 15 | rpc List(ListPermissionsRequest) returns (ListPermissionsResponse) {}; 16 | rpc Get(GetPermissionRequest) returns (GetPermissionResponse) {}; 17 | } 18 | 19 | message RegisterPermissionsRequest { 20 | repeated string permissions = 1; 21 | } 22 | 23 | message RegisterPermissionsResponse { 24 | int64 created = 1; 25 | int64 removed = 2; 26 | int64 untouched = 3; 27 | } 28 | 29 | message ListPermissionsRequest { 30 | reserved 6 to 99; 31 | 32 | qtypes.String subsystem = 1; 33 | qtypes.String module = 2; 34 | qtypes.String action = 3; 35 | qtypes.Timestamp created_at = 4; 36 | qtypes.Int64 created_by = 5; 37 | 38 | ntypes.Int64 offset = 100; 39 | ntypes.Int64 limit = 101; 40 | map sort = 102 [deprecated=true]; 41 | repeated Order order_by = 103; 42 | } 43 | 44 | message ListPermissionsResponse { 45 | repeated string permissions = 1; 46 | } 47 | 48 | message GetPermissionRequest { 49 | int64 id = 1; 50 | } 51 | 52 | message GetPermissionResponse { 53 | string permission = 1; 54 | } 55 | -------------------------------------------------------------------------------- /internal/charond/handler_get_permission.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/session" 11 | 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | type getPermissionHandler struct { 16 | *handler 17 | } 18 | 19 | func (gph *getPermissionHandler) Get(ctx context.Context, req *charonrpc.GetPermissionRequest) (*charonrpc.GetPermissionResponse, error) { 20 | if req.Id < 1 { 21 | return nil, grpcerr.E(codes.InvalidArgument, "permission id needs to be greater than zero") 22 | } 23 | 24 | act, err := gph.Actor(ctx) 25 | if err != nil { 26 | return nil, err 27 | } 28 | if err = gph.firewall(req, act); err != nil { 29 | return nil, err 30 | } 31 | 32 | permission, err := gph.repository.permission.FindOneByID(ctx, req.Id) 33 | if err != nil { 34 | if err == sql.ErrNoRows { 35 | return nil, grpcerr.E(codes.NotFound, "permission does not exists") 36 | } 37 | return nil, grpcerr.E(codes.Internal, "permission cannot be fetched", err) 38 | } 39 | 40 | return &charonrpc.GetPermissionResponse{ 41 | Permission: permission.Permission().String(), 42 | }, nil 43 | } 44 | 45 | func (gph *getPermissionHandler) firewall(req *charonrpc.GetPermissionRequest, act *session.Actor) error { 46 | if act.User.IsSuperuser { 47 | return nil 48 | } 49 | if act.Permissions.Contains(charon.PermissionCanRetrieve) { 50 | return nil 51 | } 52 | 53 | return grpcerr.E(codes.PermissionDenied, "permission cannot be retrieved, missing permission") 54 | } 55 | -------------------------------------------------------------------------------- /internal/service/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/piotrkowalczuk/zapstackdriver" 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | const ( 12 | production = "production" 13 | development = "development" 14 | stackdriver = "stackdriver" 15 | ) 16 | 17 | type Opts struct { 18 | Environment string 19 | Level string 20 | } 21 | 22 | // Init allocates new logger based on given options. 23 | func Init(service, version string, opts Opts) (logger *zap.Logger, err error) { 24 | if service == "" { 25 | return nil, errors.New("logger: service name is missing, logger cannot be initialized") 26 | } 27 | if version == "" { 28 | return nil, errors.New("logger: service version is missing, logger cannot be initialized") 29 | } 30 | 31 | var ( 32 | zapCfg zap.Config 33 | zapOpts []zap.Option 34 | lvl zapcore.Level 35 | ) 36 | switch opts.Environment { 37 | case production: 38 | zapCfg = zap.NewProductionConfig() 39 | case stackdriver: 40 | zapCfg = zapstackdriver.NewStackdriverConfig() 41 | case development: 42 | zapCfg = zap.NewDevelopmentConfig() 43 | default: 44 | zapCfg = zap.NewProductionConfig() 45 | } 46 | 47 | if err = lvl.Set(opts.Level); err != nil { 48 | return nil, err 49 | } 50 | zapCfg.Level.SetLevel(lvl) 51 | 52 | logger, err = zapCfg.Build(zapOpts...) 53 | if err != nil { 54 | return nil, err 55 | } 56 | logger = logger.With(zap.Object("serviceContext", &zapstackdriver.ServiceContext{ 57 | Service: service, 58 | Version: version, 59 | })) 60 | logger.Info("logger has been initialized", zap.String("environment", opts.Environment)) 61 | 62 | return logger, nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/charond/handler_is_authenticated.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/golang/protobuf/ptypes/wrappers" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/session" 11 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 12 | "google.golang.org/grpc/codes" 13 | "google.golang.org/grpc/status" 14 | ) 15 | 16 | type isAuthenticatedHandler struct { 17 | *handler 18 | } 19 | 20 | func (iah *isAuthenticatedHandler) IsAuthenticated(ctx context.Context, req *charonrpc.IsAuthenticatedRequest) (*wrappers.BoolValue, error) { 21 | if req.AccessToken == "" { 22 | return nil, grpcerr.E(codes.InvalidArgument, "authentication status cannot be checked, missing access token") 23 | } 24 | 25 | ses, err := iah.session.Get(ctx, &mnemosynerpc.GetRequest{AccessToken: req.AccessToken}) 26 | if err != nil { 27 | if st, ok := status.FromError(err); ok { 28 | if st.Code() == codes.NotFound { 29 | return &wrappers.BoolValue{Value: false}, nil 30 | } 31 | } 32 | return nil, grpcerr.E(codes.Internal, "session cannot be fetched", err) 33 | } 34 | uid, err := session.ActorID(ses.Session.SubjectId).UserID() 35 | if err != nil { 36 | return nil, grpcerr.E(codes.Internal, "invalid actor id", err) 37 | } 38 | exists, err := iah.repository.user.Exists(ctx, uid) 39 | if err != nil { 40 | if err == sql.ErrNoRows { 41 | return &wrappers.BoolValue{Value: false}, nil 42 | } 43 | return nil, grpcerr.E(codes.Internal, "user cannot be fetched", err) 44 | } 45 | 46 | return &wrappers.BoolValue{Value: exists}, nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/model/group_permissions_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/piotrkowalczuk/ntypes" 8 | ) 9 | 10 | var ( 11 | groupPermissionsTestFixtures = []*GroupEntity{ 12 | { 13 | ID: 1, 14 | Name: "group_1", 15 | Description: ntypes.String{Chars: "first group", Valid: true}, 16 | Permissions: []*PermissionEntity{ 17 | { 18 | ID: 1, 19 | Subsystem: "subsystem_1", 20 | Module: "module_1", 21 | Action: "action_1", 22 | }, 23 | }, 24 | }, 25 | { 26 | ID: 2, 27 | Name: "group_2", 28 | Description: ntypes.String{Chars: "second group", Valid: true}, 29 | Permissions: []*PermissionEntity{ 30 | { 31 | ID: 2, 32 | Subsystem: "subsystem_2", 33 | Module: "module_2", 34 | Action: "action_2", 35 | }, 36 | }, 37 | }, 38 | } 39 | ) 40 | 41 | type groupPermissionsFixtures struct { 42 | got, given GroupPermissionsEntity 43 | } 44 | 45 | func loadGroupPermissionsFixtures(t *testing.T, r GroupPermissionsProvider, f []*GroupPermissionsEntity) chan groupPermissionsFixtures { 46 | data := make(chan groupPermissionsFixtures, 1) 47 | 48 | go func() { 49 | for _, given := range f { 50 | entity, err := r.Insert(context.TODO(), given) 51 | if err != nil { 52 | t.Errorf("group permission cannot be created, unexpected error: %s", err.Error()) 53 | continue 54 | } else { 55 | t.Log("group permission has been created") 56 | } 57 | 58 | data <- groupPermissionsFixtures{ 59 | got: *entity, 60 | given: *given, 61 | } 62 | } 63 | 64 | close(data) 65 | }() 66 | 67 | return data 68 | } 69 | -------------------------------------------------------------------------------- /internal/charonctl/console_obtain_refresh_token.go: -------------------------------------------------------------------------------- 1 | package charonctl 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/golang/protobuf/ptypes" 9 | "github.com/golang/protobuf/ptypes/timestamp" 10 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 11 | "github.com/piotrkowalczuk/ntypes" 12 | ) 13 | 14 | type ObtainRefreshTokenArg struct { 15 | Notes string 16 | ExpireAfter time.Duration 17 | } 18 | 19 | type consoleObtainRefreshToken struct { 20 | refreshToken charonrpc.RefreshTokenManagerClient 21 | } 22 | 23 | func (cort *consoleObtainRefreshToken) ObtainRefreshToken(ctx context.Context, arg *ObtainRefreshTokenArg) error { 24 | var ( 25 | expireAt *timestamp.Timestamp 26 | err error 27 | ) 28 | if arg.ExpireAfter != time.Duration(0) { 29 | if expireAt, err = ptypes.TimestampProto(time.Now().Add(arg.ExpireAfter)); err != nil { 30 | return err 31 | } 32 | } 33 | 34 | _, err = cort.refreshToken.Create(ctx, &charonrpc.CreateRefreshTokenRequest{ 35 | Notes: &ntypes.String{Chars: arg.Notes, Valid: arg.Notes != ""}, 36 | ExpireAt: expireAt, 37 | }) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | res, err := cort.refreshToken.List(ctx, &charonrpc.ListRefreshTokensRequest{}) 43 | if err != nil { 44 | return err 45 | } 46 | for _, rt := range res.RefreshTokens { 47 | eat, err := ptypes.Timestamp(rt.ExpireAt) 48 | if err != nil { 49 | fmt.Printf("%-36s ", "never") 50 | } else { 51 | fmt.Printf("%-36s ", eat.String()) 52 | } 53 | 54 | fmt.Printf("%s", rt.Token) 55 | if rt.Notes != nil && rt.Notes.Valid { 56 | fmt.Printf(" - %s", rt.Notes.StringOr("")) 57 | } 58 | fmt.Print("\n") 59 | } 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /internal/charond/handler_belongs_to.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/golang/protobuf/ptypes/wrappers" 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/session" 11 | "google.golang.org/grpc/codes" 12 | ) 13 | 14 | type belongsToHandler struct { 15 | *handler 16 | } 17 | 18 | func (bth *belongsToHandler) BelongsTo(ctx context.Context, req *charonrpc.BelongsToRequest) (*wrappers.BoolValue, error) { 19 | if req.GroupId < 1 { 20 | return nil, grpcerr.E(codes.InvalidArgument, "group id needs to be greater than zero") 21 | } 22 | if req.UserId < 1 { 23 | return nil, grpcerr.E(codes.InvalidArgument, "user id needs to be greater than zero") 24 | } 25 | 26 | act, err := bth.Actor(ctx) 27 | if err != nil { 28 | return nil, err 29 | } 30 | if err = bth.firewall(req, act); err != nil { 31 | return nil, err 32 | } 33 | 34 | belongs, err := bth.repository.userGroups.Exists(ctx, req.UserId, req.GroupId) 35 | if err != nil { 36 | return nil, grpcerr.E(codes.Internal, "user group fetch failure", err) 37 | } 38 | 39 | return &wrappers.BoolValue{Value: belongs}, nil 40 | } 41 | 42 | func (bth *belongsToHandler) firewall(req *charonrpc.BelongsToRequest, act *session.Actor) error { 43 | if act.User.ID == req.UserId { 44 | return nil 45 | } 46 | if act.User.IsSuperuser { 47 | return nil 48 | } 49 | if act.Permissions.Contains(charon.UserGroupCanCheckBelongingAsStranger) { 50 | return nil 51 | } 52 | 53 | return grpcerr.E(codes.PermissionDenied, "group belonging cannot be checked, missing permission") 54 | } 55 | -------------------------------------------------------------------------------- /internal/model/user_permissions_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | var ( 9 | userPermissionsTestFixtures = []*UserEntity{ 10 | { 11 | ID: 1, 12 | Username: "user1@example.com", 13 | FirstName: "first_name_1", 14 | LastName: "last_name_1", 15 | Password: []byte("0123456789"), 16 | Permissions: []*PermissionEntity{ 17 | { 18 | ID: 1, 19 | Subsystem: "subsystem_1", 20 | Module: "module_1", 21 | Action: "action_1", 22 | }, 23 | }, 24 | }, 25 | { 26 | ID: 2, 27 | Username: "user2@example.com", 28 | FirstName: "first_name_2", 29 | LastName: "last_name_2", 30 | Password: []byte("9876543210"), 31 | Permissions: []*PermissionEntity{ 32 | { 33 | ID: 2, 34 | Subsystem: "subsystem_2", 35 | Module: "module_2", 36 | Action: "action_2", 37 | }, 38 | }, 39 | }, 40 | } 41 | ) 42 | 43 | type userPermissionsFixtures struct { 44 | got, given UserPermissionsEntity 45 | } 46 | 47 | func loadUserPermissionsFixtures(t *testing.T, r UserPermissionsProvider, f []*UserPermissionsEntity) chan userPermissionsFixtures { 48 | data := make(chan userPermissionsFixtures, 1) 49 | 50 | go func() { 51 | for _, given := range f { 52 | entity, err := r.Insert(context.TODO(), given) 53 | if err != nil { 54 | t.Errorf("user permission cannot be created, unexpected error: %s", err.Error()) 55 | continue 56 | } else { 57 | t.Log("user permission has been created") 58 | } 59 | 60 | data <- userPermissionsFixtures{ 61 | got: *entity, 62 | given: *given, 63 | } 64 | } 65 | 66 | close(data) 67 | }() 68 | 69 | return data 70 | } 71 | -------------------------------------------------------------------------------- /internal/charond/handler_is_granted.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/golang/protobuf/ptypes/wrappers" 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/session" 11 | 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | type isGrantedHandler struct { 16 | *handler 17 | } 18 | 19 | func (ig *isGrantedHandler) IsGranted(ctx context.Context, req *charonrpc.IsGrantedRequest) (*wrappers.BoolValue, error) { 20 | if req.Permission == "" { 21 | return nil, grpcerr.E(codes.InvalidArgument, "permission cannot be empty") 22 | } 23 | if req.UserId < 1 { 24 | return nil, grpcerr.E(codes.InvalidArgument, "user id needs to be greater than zero") 25 | } 26 | 27 | act, err := ig.Actor(ctx) 28 | if err != nil { 29 | return nil, err 30 | } 31 | if err = ig.firewall(req, act); err != nil { 32 | return nil, err 33 | } 34 | 35 | granted, err := ig.repository.user.IsGranted(ctx, req.UserId, charon.Permission(req.Permission)) 36 | if err != nil { 37 | return nil, grpcerr.E(codes.Internal, "is granted repository call failure", err) 38 | } 39 | 40 | return &wrappers.BoolValue{Value: granted}, nil 41 | } 42 | 43 | func (ig *isGrantedHandler) firewall(req *charonrpc.IsGrantedRequest, act *session.Actor) error { 44 | if act.User.ID == req.UserId { 45 | return nil 46 | } 47 | if act.User.IsSuperuser { 48 | return nil 49 | } 50 | if act.Permissions.Contains(charon.UserPermissionCanCheckGrantingAsStranger) { 51 | return nil 52 | } 53 | 54 | return grpcerr.E(codes.PermissionDenied, "group granting cannot be checked, missing permission") 55 | } 56 | -------------------------------------------------------------------------------- /data/test-selfsigned.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEA7Sklr3QwUhvAetYASNTip5w7dL+xb+VYv4ErAJOGW4uGrun1 3 | kTbR9JVz0L3gR3Ss4htNcPCunzSeWWwvO82+FnUGL1uzRByHWaXMDo8IYEPpAjl9 4 | 1rMaV+4fvQGqhcUyK+MKoFVb0E1xcd+1CbjWerV91IqTa50LCmQj1swQfirEGacP 5 | 19JTNkm74QcqVK1vYPAVFsy0ng2kMBV//52s5wOgj2PpDcaICccy6QIuXjAkO3u9 6 | QZDhai4bot+lBjP5j/0F98LaYKaHmVmpv76TxrmBHQ6/AHSHQ+rI98K1Nj+SUmJ9 7 | 8zd9/Qy5vyqtx4aSYTOqMsvV1l9WpFAsOea+xQIDAQABAoIBAD45XIzjVEZGx8Ky 8 | 4VI6oNlPMX5ZSUnNh/J/BnRZQJhGTGkaM3cNRhsBF2j+WJkG8NFGTpRCYd6dpKpb 9 | qyzqt2QXAi9sxOUrAwwvZxGuz4jKaJlP0keBqHjHnoYtqLr+WUKQiFo26ycFGq3A 10 | /zP0zjlV4xTf4vwKMTphudGCqxUezBNIsB0G/6XALY7JCgQ2ABsW4LVojcU3Iktv 11 | EPEEeThrgiqbclxvO1HpecBrKUIDwnp2LBA8Yb23stWjHhkDiJe/FbPJh5t4vdzS 12 | Q+bk9XqQKXRz3ULZJEi7zuuUVEs2z+IoHVZBavMYReEdq3Qrn/EFvouaEL0zIbWM 13 | l9WqKQUCgYEA+dIZHNXNPKTBQHI7wn2IXxZ3IaNA6Mwsz/d2fgYKMWl7nP37UXAb 14 | sFntMGPAr01Ca3VWUcD5uIIs1XqMV6UMQkMF0XXcfgRATVmp6xFqkc5vBVQsAAD8 15 | lMZkeikZ9h9+Y4Zym4n7dnrmbJxk8sUuhMyqcnm6iFCZVhcTsm6Ak98CgYEA8wbi 16 | Y2q50auazgRwubiQgYcvqP3xBHFJN2WbgOEfrfoxZsPt8enmdTrotZh2dTzGDXvU 17 | ikyy8PSbzcSL4Xveqo1kGsDExHkZKXn4p96/BHabRAyyL38NmZSslxU9XF9+/TR7 18 | H/9W5jI6LV+zdFK1XqLRfZ399UrCV08iOPA3odsCgYAoXjjMngfCCuVnYo4hiWNl 19 | 6h1qBBVTCNsc5+Hvz04KWf8tiST5LeJrhrx7G0NhkFxxPM6r+0De/bn87QaMixEG 20 | DAp+pEry2gEB/sEkSPYthWwPMmDBx2cJK13FF6soSEKGFo2icJN/u7BMUKFHUXGI 21 | 6AGK6fyoVk3QyX3XUV/ZhQKBgQDJmTNhXx5IBP+E2QAdwDH3kQoDOKyfj845qwsF 22 | LTrXWo1yfyO6otou8zApvBDADisI8mkMVLW31mIMnnefE99RQbsBylkv1nj+BBU2 23 | RDvW2wDPwWxqpA3HEiLdrZvaLcmtB8T/dRREHvRNwW6fFyEnIQ+BEfeibGKshJOS 24 | AgtUMwKBgHRvNHh84wVtegWqXAcXAYYhVqwAjwj1jnzo2IxNm6cKZbXTLKxjXUdS 25 | GIyY/3y4lVdxp+YNVSMROX6YNnGVP6GtgQzQ3ShWuK9uXvNL628EFeXYq8ydD4Jr 26 | uDfX1gdLIjoqUpy9aQ/1fyCFftyKxapkDVPqq+1QKZora+oyOX6X 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /internal/charond/handler_list_user_groups.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/piotrkowalczuk/charon" 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 9 | "github.com/piotrkowalczuk/charon/internal/mapping" 10 | "github.com/piotrkowalczuk/charon/internal/session" 11 | 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | type listUserGroupsHandler struct { 16 | *handler 17 | } 18 | 19 | func (lugh *listUserGroupsHandler) ListGroups(ctx context.Context, req *charonrpc.ListUserGroupsRequest) (*charonrpc.ListUserGroupsResponse, error) { 20 | if req.Id <= 0 { 21 | return nil, grpcerr.E(codes.InvalidArgument, "missing user id") 22 | } 23 | act, err := lugh.Actor(ctx) 24 | if err != nil { 25 | return nil, err 26 | } 27 | if err = lugh.firewall(req, act); err != nil { 28 | return nil, err 29 | } 30 | 31 | ents, err := lugh.repository.group.FindByUserID(ctx, req.Id) 32 | if err != nil { 33 | return nil, grpcerr.E(codes.Internal, "find groups by user id query failed", err) 34 | } 35 | 36 | msg, err := mapping.ReverseGroups(ents) 37 | if err != nil { 38 | return nil, grpcerr.E(codes.Internal, "user group entities mapping failure", err) 39 | } 40 | 41 | return &charonrpc.ListUserGroupsResponse{Groups: msg}, nil 42 | } 43 | 44 | func (lugh *listUserGroupsHandler) firewall(req *charonrpc.ListUserGroupsRequest, act *session.Actor) error { 45 | switch { 46 | case act.User.IsSuperuser: 47 | return nil 48 | case act.User.ID == req.Id: 49 | return nil 50 | case act.Permissions.Contains(charon.UserGroupCanRetrieve): 51 | return nil 52 | } 53 | 54 | return grpcerr.E(codes.PermissionDenied, "list of user groups cannot be retrieved, missing permission") 55 | } 56 | -------------------------------------------------------------------------------- /internal/model/modelmock/user_permissions_provider.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package modelmock 4 | 5 | import context "context" 6 | import mock "github.com/stretchr/testify/mock" 7 | import model "github.com/piotrkowalczuk/charon/internal/model" 8 | 9 | // UserPermissionsProvider is an autogenerated mock type for the UserPermissionsProvider type 10 | type UserPermissionsProvider struct { 11 | mock.Mock 12 | } 13 | 14 | // DeleteByUserID provides a mock function with given fields: _a0, _a1 15 | func (_m *UserPermissionsProvider) DeleteByUserID(_a0 context.Context, _a1 int64) (int64, error) { 16 | ret := _m.Called(_a0, _a1) 17 | 18 | var r0 int64 19 | if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok { 20 | r0 = rf(_a0, _a1) 21 | } else { 22 | r0 = ret.Get(0).(int64) 23 | } 24 | 25 | var r1 error 26 | if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { 27 | r1 = rf(_a0, _a1) 28 | } else { 29 | r1 = ret.Error(1) 30 | } 31 | 32 | return r0, r1 33 | } 34 | 35 | // Insert provides a mock function with given fields: _a0, _a1 36 | func (_m *UserPermissionsProvider) Insert(_a0 context.Context, _a1 *model.UserPermissionsEntity) (*model.UserPermissionsEntity, error) { 37 | ret := _m.Called(_a0, _a1) 38 | 39 | var r0 *model.UserPermissionsEntity 40 | if rf, ok := ret.Get(0).(func(context.Context, *model.UserPermissionsEntity) *model.UserPermissionsEntity); ok { 41 | r0 = rf(_a0, _a1) 42 | } else { 43 | if ret.Get(0) != nil { 44 | r0 = ret.Get(0).(*model.UserPermissionsEntity) 45 | } 46 | } 47 | 48 | var r1 error 49 | if rf, ok := ret.Get(1).(func(context.Context, *model.UserPermissionsEntity) error); ok { 50 | r1 = rf(_a0, _a1) 51 | } else { 52 | r1 = ret.Error(1) 53 | } 54 | 55 | return r0, r1 56 | } 57 | -------------------------------------------------------------------------------- /internal/charond/handler_list_user_permissions.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/piotrkowalczuk/charon" 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 9 | "github.com/piotrkowalczuk/charon/internal/session" 10 | "google.golang.org/grpc/codes" 11 | ) 12 | 13 | type listUserPermissionsHandler struct { 14 | *handler 15 | } 16 | 17 | func (luph *listUserPermissionsHandler) ListPermissions(ctx context.Context, req *charonrpc.ListUserPermissionsRequest) (*charonrpc.ListUserPermissionsResponse, error) { 18 | if req.Id <= 0 { 19 | return nil, grpcerr.E(codes.InvalidArgument, "missing user id") 20 | } 21 | act, err := luph.Actor(ctx) 22 | if err != nil { 23 | return nil, err 24 | } 25 | if err = luph.firewall(req, act); err != nil { 26 | return nil, err 27 | } 28 | 29 | permissions, err := luph.repository.permission.FindByUserID(ctx, req.Id) 30 | if err != nil { 31 | return nil, grpcerr.E(codes.Internal, "find permissions by user id query failed", err) 32 | } 33 | 34 | perms := make([]string, 0, len(permissions)) 35 | for _, p := range permissions { 36 | perms = append(perms, p.Permission().String()) 37 | } 38 | 39 | return &charonrpc.ListUserPermissionsResponse{ 40 | Permissions: perms, 41 | }, nil 42 | } 43 | 44 | func (luph *listUserPermissionsHandler) firewall(req *charonrpc.ListUserPermissionsRequest, act *session.Actor) error { 45 | if act.User.IsSuperuser { 46 | return nil 47 | } 48 | if act.User.ID == req.Id { 49 | return nil 50 | } 51 | if act.Permissions.Contains(charon.UserPermissionCanRetrieve) { 52 | return nil 53 | } 54 | 55 | return grpcerr.E(codes.PermissionDenied, "list of user permissions cannot be retrieved, missing permission") 56 | } 57 | -------------------------------------------------------------------------------- /internal/model/refresh_token.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | ) 7 | 8 | // RefreshTokenProvider ... 9 | type RefreshTokenProvider interface { 10 | // Find ... 11 | Find(context.Context, *RefreshTokenFindExpr) ([]*RefreshTokenEntity, error) 12 | // FindOneByToken ... 13 | FindOneByToken(context.Context, string) (*RefreshTokenEntity, error) 14 | // Create ... 15 | Create(context.Context, *RefreshTokenEntity) (*RefreshTokenEntity, error) 16 | // UpdateOneByToken ... 17 | UpdateOneByToken(context.Context, string, *RefreshTokenPatch) (*RefreshTokenEntity, error) 18 | // FindOneByTokenAndUserID . 19 | FindOneByTokenAndUserID(ctx context.Context, token string, userID int64) (*RefreshTokenEntity, error) 20 | } 21 | 22 | // RefreshTokenRepository extends RefreshTokenRepositoryBase 23 | type RefreshTokenRepository struct { 24 | RefreshTokenRepositoryBase 25 | } 26 | 27 | // NewRefreshTokenRepository ... 28 | func NewRefreshTokenRepository(dbPool *sql.DB) RefreshTokenProvider { 29 | return &RefreshTokenRepository{ 30 | RefreshTokenRepositoryBase: RefreshTokenRepositoryBase{ 31 | DB: dbPool, 32 | Table: TableRefreshToken, 33 | Columns: TableRefreshTokenColumns, 34 | }, 35 | } 36 | } 37 | 38 | // Create ... 39 | func (rtr *RefreshTokenRepository) Create(ctx context.Context, ent *RefreshTokenEntity) (*RefreshTokenEntity, error) { 40 | return rtr.Insert(ctx, ent) 41 | } 42 | 43 | // FindOneByTokenAndUserID ... 44 | func (rtr *RefreshTokenRepository) FindOneByTokenAndUserID(ctx context.Context, token string, userID int64) (*RefreshTokenEntity, error) { 45 | ent, err := rtr.FindOneByToken(ctx, token) 46 | if err != nil { 47 | return nil, err 48 | } 49 | if ent.UserID != userID { 50 | return nil, sql.ErrNoRows 51 | } 52 | return ent, nil 53 | } 54 | -------------------------------------------------------------------------------- /internal/charond/handler_logout_test.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "google.golang.org/grpc/codes" 9 | "google.golang.org/grpc/status" 10 | ) 11 | 12 | func TestLogoutHandler_Logout(t *testing.T) { 13 | suite := &endToEndSuite{} 14 | suite.setup(t) 15 | defer suite.teardown(t) 16 | 17 | tkn, err := suite.charon.auth.Login(context.TODO(), &charonrpc.LoginRequest{ 18 | Username: "test", 19 | Password: "test", 20 | }) 21 | if err != nil { 22 | t.Fatalf("unexpected login error: %s: with code %s", status.Convert(err).Message(), status.Code(err)) 23 | } 24 | 25 | if _, err := suite.charon.auth.Logout(context.TODO(), &charonrpc.LogoutRequest{ 26 | AccessToken: tkn.Value, 27 | }); err != nil { 28 | t.Errorf("logout failure: %s", err.Error()) 29 | } 30 | ok, err := suite.charon.auth.IsAuthenticated(context.TODO(), &charonrpc.IsAuthenticatedRequest{ 31 | AccessToken: tkn.Value, 32 | }) 33 | if err != nil { 34 | t.Fatalf("unexpected is authenticated error: %s: with code %s", status.Convert(err).Message(), status.Code(err)) 35 | } 36 | if ok.Value { 37 | t.Errorf("user should not be authenticated") 38 | } 39 | } 40 | 41 | func TestLogoutHandler_Logout_missingToken(t *testing.T) { 42 | suite := &endToEndSuite{} 43 | suite.setup(t) 44 | defer suite.teardown(t) 45 | 46 | _, err := suite.charon.auth.Logout(context.TODO(), &charonrpc.LogoutRequest{}) 47 | if err == nil { 48 | t.Fatal("error should not be nil") 49 | } 50 | if st, ok := status.FromError(err); ok { 51 | if st.Code() != codes.InvalidArgument { 52 | t.Errorf("wrong status code, expected %s but got %s", codes.InvalidArgument.String(), st.Code().String()) 53 | } 54 | } else { 55 | t.Error("wrong error type") 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /data/test-selfsigned.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE6zCCA9OgAwIBAgIJAKst8YkJ6qNBMA0GCSqGSIb3DQEBCwUAMIGpMQswCQYD 3 | VQQGEwJQTDEVMBMGA1UECBQMRG9sbnnFm2zEhXNrMRMwEQYDVQQHFApXYcWCYnJ6 4 | eWNoMRQwEgYDVQQKEwtDaGFyb24gSW5jLjEUMBIGA1UECxMLT3BlbiBTb3VyY2Ux 5 | FzAVBgNVBAMTDnRlc3QubG9jYWwudGxkMSkwJwYJKoZIhvcNAQkBFhpwLmtvd2Fs 6 | Y3p1ay5wcml2QGdtYWlsLmNvbTAeFw0xNzA3MDkxMjM3NTBaFw0yNzA3MDcxMjM3 7 | NTBaMIGpMQswCQYDVQQGEwJQTDEVMBMGA1UECBQMRG9sbnnFm2zEhXNrMRMwEQYD 8 | VQQHFApXYcWCYnJ6eWNoMRQwEgYDVQQKEwtDaGFyb24gSW5jLjEUMBIGA1UECxML 9 | T3BlbiBTb3VyY2UxFzAVBgNVBAMTDnRlc3QubG9jYWwudGxkMSkwJwYJKoZIhvcN 10 | AQkBFhpwLmtvd2FsY3p1ay5wcml2QGdtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEB 11 | BQADggEPADCCAQoCggEBAO0pJa90MFIbwHrWAEjU4qecO3S/sW/lWL+BKwCThluL 12 | hq7p9ZE20fSVc9C94Ed0rOIbTXDwrp80nllsLzvNvhZ1Bi9bs0Qch1mlzA6PCGBD 13 | 6QI5fdazGlfuH70BqoXFMivjCqBVW9BNcXHftQm41nq1fdSKk2udCwpkI9bMEH4q 14 | xBmnD9fSUzZJu+EHKlStb2DwFRbMtJ4NpDAVf/+drOcDoI9j6Q3GiAnHMukCLl4w 15 | JDt7vUGQ4WouG6LfpQYz+Y/9BffC2mCmh5lZqb++k8a5gR0OvwB0h0PqyPfCtTY/ 16 | klJiffM3ff0Mub8qrceGkmEzqjLL1dZfVqRQLDnmvsUCAwEAAaOCARIwggEOMB0G 17 | A1UdDgQWBBTwx64SsuKAoojTBND16m4uhzQ0/jCB3gYDVR0jBIHWMIHTgBTwx64S 18 | suKAoojTBND16m4uhzQ0/qGBr6SBrDCBqTELMAkGA1UEBhMCUEwxFTATBgNVBAgU 19 | DERvbG55xZtsxIVzazETMBEGA1UEBxQKV2HFgmJyenljaDEUMBIGA1UEChMLQ2hh 20 | cm9uIEluYy4xFDASBgNVBAsTC09wZW4gU291cmNlMRcwFQYDVQQDEw50ZXN0Lmxv 21 | Y2FsLnRsZDEpMCcGCSqGSIb3DQEJARYacC5rb3dhbGN6dWsucHJpdkBnbWFpbC5j 22 | b22CCQCrLfGJCeqjQTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQDs 23 | g029gFSZyJ4g5BHsSOEXZ2Nc5vRzrZWu/I9Ci5ztMAM/EzE5WIeBIVna1cfrLwL8 24 | qIwqiqkjrXiH6aEF1zH/UZVL8jen+s9y7Q1WOwvGqIAJQlbUEsMBa4vrmP203hVI 25 | oYdFlEi1+tfRDxQM9e86To4l/hAjvyjQuj31v45pUETZ/bVt+R4KUGaxVf3ETw36 26 | BwcX6KWDKVCs+ie0k0c1SppfGCxDFDQdpFwKxd/9RcJ2Oc6zcnb1i1577UVjYoES 27 | T+M/Ayyho6mGACaFwlBHmPHmfVJf6hj09pt6Z1938oGm19zj/ZKRSl1GtrST72p0 28 | KuvB5qQWEM5mhh1oZUzT 29 | -----END CERTIFICATE----- 30 | -------------------------------------------------------------------------------- /internal/model/modelmock/permission_registry.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package modelmock 4 | 5 | import charon "github.com/piotrkowalczuk/charon" 6 | import context "context" 7 | import mock "github.com/stretchr/testify/mock" 8 | 9 | // PermissionRegistry is an autogenerated mock type for the PermissionRegistry type 10 | type PermissionRegistry struct { 11 | mock.Mock 12 | } 13 | 14 | // Exists provides a mock function with given fields: ctx, permission 15 | func (_m *PermissionRegistry) Exists(ctx context.Context, permission charon.Permission) bool { 16 | ret := _m.Called(ctx, permission) 17 | 18 | var r0 bool 19 | if rf, ok := ret.Get(0).(func(context.Context, charon.Permission) bool); ok { 20 | r0 = rf(ctx, permission) 21 | } else { 22 | r0 = ret.Get(0).(bool) 23 | } 24 | 25 | return r0 26 | } 27 | 28 | // Register provides a mock function with given fields: ctx, permissions 29 | func (_m *PermissionRegistry) Register(ctx context.Context, permissions charon.Permissions) (int64, int64, int64, error) { 30 | ret := _m.Called(ctx, permissions) 31 | 32 | var r0 int64 33 | if rf, ok := ret.Get(0).(func(context.Context, charon.Permissions) int64); ok { 34 | r0 = rf(ctx, permissions) 35 | } else { 36 | r0 = ret.Get(0).(int64) 37 | } 38 | 39 | var r1 int64 40 | if rf, ok := ret.Get(1).(func(context.Context, charon.Permissions) int64); ok { 41 | r1 = rf(ctx, permissions) 42 | } else { 43 | r1 = ret.Get(1).(int64) 44 | } 45 | 46 | var r2 int64 47 | if rf, ok := ret.Get(2).(func(context.Context, charon.Permissions) int64); ok { 48 | r2 = rf(ctx, permissions) 49 | } else { 50 | r2 = ret.Get(2).(int64) 51 | } 52 | 53 | var r3 error 54 | if rf, ok := ret.Get(3).(func(context.Context, charon.Permissions) error); ok { 55 | r3 = rf(ctx, permissions) 56 | } else { 57 | r3 = ret.Error(3) 58 | } 59 | 60 | return r0, r1, r2, r3 61 | } 62 | -------------------------------------------------------------------------------- /cmd/charond/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | _ "github.com/lib/pq" 8 | "github.com/piotrkowalczuk/charon/internal/charond" 9 | "github.com/piotrkowalczuk/charon/internal/service/logger" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | var ( 14 | config configuration 15 | service = "charond" 16 | ) 17 | 18 | func init() { 19 | config.init() 20 | } 21 | 22 | func main() { 23 | config.parse() 24 | 25 | log, err := logger.Init(service, version, logger.Opts{ 26 | Environment: config.logger.environment, 27 | Level: config.logger.level, 28 | }) 29 | if err != nil { 30 | fmt.Println(err) 31 | os.Exit(1) 32 | } 33 | rpcListener := initListener(log, config.host, config.port) 34 | debugListener := initListener(log, config.host, config.port+1) 35 | 36 | // TODO: update and make it optional 37 | //grpclog.SetLogger(sklog.NewGRPCLogger(logger)) 38 | 39 | daemon := charond.NewDaemon(charond.DaemonOpts{ 40 | Test: config.test, 41 | TLS: config.tls.enabled, 42 | TLSCertFile: config.tls.certFile, 43 | TLSKeyFile: config.tls.keyFile, 44 | Monitoring: config.monitoring.enabled, 45 | PostgresAddress: config.postgres.address + "&application_name=charond_" + version, 46 | PostgresDebug: config.postgres.debug, 47 | PasswordBCryptCost: config.password.bcrypt.cost, 48 | MnemosyneAddress: config.mnemosyned.address, 49 | MnemosyneTLS: config.mnemosyned.tls.enabled, 50 | MnemosyneTLSCertFile: config.mnemosyned.tls.certFile, 51 | Logger: log.Named("daemon"), 52 | RPCListener: rpcListener, 53 | DebugListener: debugListener, 54 | }) 55 | 56 | if err := daemon.Run(); err != nil { 57 | log.Fatal("daemon returned an error", zap.Error(err)) 58 | } 59 | defer daemon.Close() 60 | 61 | done := make(chan struct{}) 62 | <-done 63 | } 64 | -------------------------------------------------------------------------------- /internal/charond/handler_list_groups.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/piotrkowalczuk/charon" 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 9 | "github.com/piotrkowalczuk/charon/internal/mapping" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/session" 12 | 13 | "google.golang.org/grpc/codes" 14 | ) 15 | 16 | type listGroupsHandler struct { 17 | *handler 18 | } 19 | 20 | func (lgh *listGroupsHandler) List(ctx context.Context, req *charonrpc.ListGroupsRequest) (*charonrpc.ListGroupsResponse, error) { 21 | act, err := lgh.Actor(ctx) 22 | if err != nil { 23 | return nil, err 24 | } 25 | if err = lgh.firewall(req, act); err != nil { 26 | return nil, err 27 | } 28 | 29 | ents, err := lgh.repository.group.Find(ctx, &model.GroupFindExpr{ 30 | Limit: req.GetLimit().Int64Or(10), 31 | Offset: req.GetOffset().Int64Or(0), 32 | OrderBy: mapping.OrderBy(req.GetOrderBy()), 33 | }) 34 | if err != nil { 35 | return nil, grpcerr.E(codes.Internal, "find group query failed", err) 36 | } 37 | 38 | return lgh.response(ents) 39 | } 40 | 41 | func (lgh *listGroupsHandler) firewall(req *charonrpc.ListGroupsRequest, act *session.Actor) error { 42 | if act.User.IsSuperuser { 43 | return nil 44 | } 45 | if act.Permissions.Contains(charon.GroupCanRetrieve) { 46 | return nil 47 | } 48 | 49 | return grpcerr.E(codes.PermissionDenied, "list of groups cannot be retrieved, missing permission") 50 | } 51 | 52 | func (lgh *listGroupsHandler) response(ents []*model.GroupEntity) (*charonrpc.ListGroupsResponse, error) { 53 | msg, err := mapping.ReverseGroups(ents) 54 | if err != nil { 55 | return nil, grpcerr.E(codes.Internal, "group entities mapping failure", err) 56 | 57 | } 58 | 59 | return &charonrpc.ListGroupsResponse{Groups: msg}, nil 60 | } 61 | -------------------------------------------------------------------------------- /internal/charond/handler_get_group.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/mapping" 11 | "github.com/piotrkowalczuk/charon/internal/model" 12 | "github.com/piotrkowalczuk/charon/internal/session" 13 | "google.golang.org/grpc/codes" 14 | ) 15 | 16 | type getGroupHandler struct { 17 | *handler 18 | } 19 | 20 | func (ggh *getGroupHandler) Get(ctx context.Context, req *charonrpc.GetGroupRequest) (*charonrpc.GetGroupResponse, error) { 21 | if req.Id <= 0 { 22 | return nil, grpcerr.E(codes.InvalidArgument, "missing group id") 23 | } 24 | act, err := ggh.Actor(ctx) 25 | if err != nil { 26 | return nil, err 27 | } 28 | if err = ggh.firewall(req, act); err != nil { 29 | return nil, err 30 | } 31 | 32 | ent, err := ggh.repository.group.FindOneByID(ctx, req.Id) 33 | if err != nil { 34 | if err == sql.ErrNoRows { 35 | return nil, grpcerr.E(codes.NotFound, "group does not exists") 36 | } 37 | return nil, grpcerr.E(codes.Internal, "group cannot be fetched", err) 38 | } 39 | 40 | return ggh.response(ent) 41 | } 42 | 43 | func (ggh *getGroupHandler) firewall(req *charonrpc.GetGroupRequest, act *session.Actor) error { 44 | if act.User.IsSuperuser { 45 | return nil 46 | } 47 | if act.Permissions.Contains(charon.GroupCanRetrieve) { 48 | return nil 49 | } 50 | 51 | return grpcerr.E(codes.PermissionDenied, "group cannot be retrieved, missing permission") 52 | } 53 | 54 | func (ggh *getGroupHandler) response(ent *model.GroupEntity) (*charonrpc.GetGroupResponse, error) { 55 | msg, err := mapping.ReverseGroup(ent) 56 | if err != nil { 57 | return nil, grpcerr.E(codes.Internal, "group entity mapping failure", err) 58 | } 59 | return &charonrpc.GetGroupResponse{ 60 | Group: msg, 61 | }, nil 62 | } 63 | -------------------------------------------------------------------------------- /internal/charond/handler_set_user_groups.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lib/pq" 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/session" 12 | 13 | "google.golang.org/grpc/codes" 14 | ) 15 | 16 | type setUserGroupsHandler struct { 17 | *handler 18 | } 19 | 20 | func (sugh *setUserGroupsHandler) SetGroups(ctx context.Context, req *charonrpc.SetUserGroupsRequest) (*charonrpc.SetUserGroupsResponse, error) { 21 | act, err := sugh.Actor(ctx) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | if err = sugh.firewall(req, act); err != nil { 27 | return nil, err 28 | } 29 | 30 | created, removed, err := sugh.repository.userGroups.Set(ctx, req.UserId, req.Groups) 31 | if err != nil { 32 | switch model.ErrorConstraint(err) { 33 | case model.TableUserGroupsConstraintGroupIDForeignKey: 34 | return nil, grpcerr.E(codes.NotFound, "%s: group does not exist", err.(*pq.Error).Detail) 35 | case model.TableUserGroupsConstraintUserIDForeignKey: 36 | return nil, grpcerr.E(codes.NotFound, "%s: user does not exist", err.(*pq.Error).Detail) 37 | default: 38 | return nil, err 39 | } 40 | } 41 | 42 | return &charonrpc.SetUserGroupsResponse{ 43 | Created: created, 44 | Removed: removed, 45 | Untouched: untouched(int64(len(req.Groups)), created, removed), 46 | }, nil 47 | } 48 | 49 | func (sugh *setUserGroupsHandler) firewall(req *charonrpc.SetUserGroupsRequest, act *session.Actor) error { 50 | if act.User.IsSuperuser { 51 | return nil 52 | } 53 | if act.Permissions.Contains(charon.UserGroupCanCreate) && act.Permissions.Contains(charon.UserGroupCanDelete) { 54 | return nil 55 | } 56 | 57 | return grpcerr.E(codes.PermissionDenied, "user groups cannot be set, missing permission") 58 | } 59 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SERVICE=charon 2 | VERSION=$(shell git describe --tags --always --dirty) 3 | ifeq ($(version),) 4 | TAG=${VERSION} 5 | else 6 | TAG=$(version) 7 | endif 8 | 9 | PACKAGE=github.com/piotrkowalczuk/charon 10 | PACKAGE_CMD_DAEMON=$(PACKAGE)/cmd/$(SERVICE)d 11 | PACKAGE_CMD_CONTROL=$(PACKAGE)/cmd/$(SERVICE)ctl 12 | PACKAGE_CMD_GENERATOR=$(PACKAGE)/cmd/$(SERVICE)g 13 | 14 | 15 | LDFLAGS = -X 'main.version=$(VERSION)' 16 | 17 | .PHONY: all version build install gen test cover get publish 18 | 19 | all: get install 20 | 21 | version: 22 | echo ${VERSION} > publish/VERSION.txt 23 | 24 | build: 25 | CGO_ENABLED=0 GOOS=linux go build -ldflags "${LDFLAGS}" -a -o bin/${SERVICE}g ${PACKAGE_CMD_GENERATOR} 26 | CGO_ENABLED=0 GOOS=linux go build -ldflags "${LDFLAGS}" -a -o bin/${SERVICE}d ${PACKAGE_CMD_DAEMON} 27 | CGO_ENABLED=0 GOOS=linux go build -ldflags "${LDFLAGS}" -a -o bin/${SERVICE}ctl ${PACKAGE_CMD_CONTROL} 28 | 29 | install: 30 | go install -ldflags "${LDFLAGS}" ${PACKAGE_CMD_GENERATOR} 31 | go install -ldflags "${LDFLAGS}" ${PACKAGE_CMD_DAEMON} 32 | go install -ldflags "${LDFLAGS}" ${PACKAGE_CMD_CONTROL} 33 | 34 | gen: 35 | #./scripts/generate.sh 36 | bash ./.circleci/scripts/generate.sh golang 37 | 38 | test: 39 | ./.circleci/scripts/test.sh 40 | go tool cover -func=cover.out | tail -n 1 41 | 42 | cover: test 43 | go tool cover -html=cover.out 44 | 45 | get: 46 | bash ./.circleci/scripts/get_tool.sh github.com/golang/protobuf/proto v1.2.0 47 | go get -u gotest.tools/gotestsum 48 | go get -u github.com/vektra/mockery/cmd/mockery 49 | go get -u github.com/golang/dep/cmd/dep 50 | dep ensure 51 | 52 | publish: 53 | docker build \ 54 | --build-arg VCS_REF=${VCS_REF} \ 55 | --build-arg BUILD_DATE=`date -u +"%Y-%m-%dT%H:%M:%SZ"` \ 56 | -t piotrkowalczuk/${SERVICE}:${TAG} . 57 | docker push piotrkowalczuk/${SERVICE}:${TAG} 58 | 59 | setup-python: 60 | python3 -m venv venv 61 | source ./venv/bin/activate.fish 62 | pip install grpc_tools 63 | pip install grpcio-tools -------------------------------------------------------------------------------- /internal/charond/handler_list_permissions.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/piotrkowalczuk/charon" 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 9 | "github.com/piotrkowalczuk/charon/internal/mapping" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/session" 12 | 13 | "google.golang.org/grpc/codes" 14 | ) 15 | 16 | type listPermissionsHandler struct { 17 | *handler 18 | } 19 | 20 | func (lph *listPermissionsHandler) List(ctx context.Context, req *charonrpc.ListPermissionsRequest) (*charonrpc.ListPermissionsResponse, error) { 21 | act, err := lph.Actor(ctx) 22 | if err != nil { 23 | return nil, err 24 | } 25 | if err = lph.firewall(req, act); err != nil { 26 | return nil, err 27 | } 28 | 29 | entities, err := lph.repository.permission.Find(ctx, &model.PermissionFindExpr{ 30 | Offset: req.Offset.Int64Or(0), 31 | Limit: req.Limit.Int64Or(10), 32 | OrderBy: mapping.OrderBy(req.OrderBy), 33 | Where: &model.PermissionCriteria{ 34 | Subsystem: req.Subsystem, 35 | Module: req.Module, 36 | Action: req.Action, 37 | }, 38 | }) 39 | if err != nil { 40 | return nil, grpcerr.E(codes.Internal, "find permission query failed", err) 41 | } 42 | 43 | permissions := make([]string, 0, len(entities)) 44 | for _, e := range entities { 45 | permissions = append(permissions, e.Permission().String()) 46 | } 47 | return &charonrpc.ListPermissionsResponse{ 48 | Permissions: permissions, 49 | }, nil 50 | } 51 | 52 | func (lph *listPermissionsHandler) firewall(req *charonrpc.ListPermissionsRequest, act *session.Actor) error { 53 | if act.User.IsSuperuser { 54 | return nil 55 | } 56 | if act.Permissions.Contains(charon.PermissionCanRetrieve) { 57 | return nil 58 | } 59 | 60 | return grpcerr.E(codes.PermissionDenied, "list of permissions cannot be retrieved, missing permission") 61 | } 62 | -------------------------------------------------------------------------------- /internal/model/group_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestGroupRepository_IsGranted(t *testing.T) { 9 | suite := &postgresSuite{} 10 | suite.setup(t) 11 | defer suite.teardown(t) 12 | 13 | for ur := range loadGroupFixtures(t, suite.repository.group, groupPermissionsTestFixtures) { 14 | for pr := range loadPermissionFixtures(t, suite.repository.permission, ur.given.Permissions) { 15 | add := []*GroupPermissionsEntity{{ 16 | GroupID: ur.got.ID, 17 | PermissionSubsystem: pr.got.Subsystem, 18 | PermissionModule: pr.got.Module, 19 | PermissionAction: pr.got.Action, 20 | }} 21 | for range loadGroupPermissionsFixtures(t, suite.repository.groupPermissions, add) { 22 | exists, err := suite.repository.group.IsGranted(context.TODO(), ur.given.ID, pr.given.Permission()) 23 | 24 | if err != nil { 25 | t.Errorf("group permission cannot be found, unexpected error: %s", err.Error()) 26 | continue 27 | } 28 | 29 | if !exists { 30 | t.Errorf("group permission not found for group %d and permission %d", ur.given.ID, pr.given.ID) 31 | } else { 32 | t.Logf("group permission relationship exists for group %d and permission %d", ur.given.ID, pr.given.ID) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | type groupFixtures struct { 40 | got, given GroupEntity 41 | } 42 | 43 | func loadGroupFixtures(t *testing.T, r GroupProvider, f []*GroupEntity) chan groupFixtures { 44 | data := make(chan groupFixtures, 1) 45 | 46 | go func() { 47 | for _, given := range f { 48 | entity, err := r.Insert(context.TODO(), given) 49 | if err != nil { 50 | t.Errorf("group cannot be created, unexpected error: %s", err.Error()) 51 | continue 52 | } else { 53 | t.Logf("group has been created, got id %d", entity.ID) 54 | } 55 | 56 | data <- groupFixtures{ 57 | got: *entity, 58 | given: *given, 59 | } 60 | } 61 | 62 | close(data) 63 | }() 64 | 65 | return data 66 | } 67 | -------------------------------------------------------------------------------- /internal/charond/suite_test.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "os" 7 | "runtime/debug" 8 | "testing" 9 | "time" 10 | 11 | _ "github.com/lib/pq" 12 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 13 | "google.golang.org/grpc/codes" 14 | "google.golang.org/grpc/status" 15 | ) 16 | 17 | var ( 18 | testPostgresAddress string 19 | ) 20 | 21 | func TestMain(m *testing.M) { 22 | flag.StringVar(&testPostgresAddress, "postgres.address", getStringEnvOr("CHAROND_POSTGRES_ADDRESS", "postgres://localhost/test?sslmode=disable"), "") 23 | flag.Parse() 24 | 25 | os.Exit(m.Run()) 26 | } 27 | 28 | func getStringEnvOr(env, or string) string { 29 | if v := os.Getenv(env); v != "" { 30 | return v 31 | } 32 | return or 33 | } 34 | 35 | func timeout(ctx context.Context) context.Context { 36 | // TODO: leak 37 | ctx, _ = context.WithTimeout(ctx, 5*time.Second) 38 | return ctx 39 | } 40 | 41 | func assertErrorCode(t *testing.T, err error, code codes.Code, msg string) { 42 | t.Helper() 43 | 44 | if err == nil { 45 | t.Fatal("expected error") 46 | } 47 | if st, ok := status.FromError(err); ok { 48 | if st.Code() != code { 49 | t.Fatalf("wrong error code, expected '%s' but got '%s' for error: %s", code, st.Code(), err.Error()) 50 | } 51 | if st.Message() != msg { 52 | t.Fatalf("wrong error message, expected '%s' but got '%s' for error: %s", msg, st.Message(), err.Error()) 53 | } 54 | } else { 55 | t.Fatalf("expected grpc error, got %T", err) 56 | } 57 | } 58 | 59 | func assertError(t *testing.T, e1, e2 error) { 60 | t.Helper() 61 | 62 | if e1 != nil { 63 | if !grpcerr.Match(e1, e2) { 64 | t.Fatalf("error do not match, got %v", e2) 65 | } 66 | } else if e2 != nil { 67 | t.Fatal(e2) 68 | } 69 | } 70 | 71 | func recoverTest(t *testing.T) { 72 | t.Helper() 73 | 74 | if err := recover(); err != nil { 75 | t.Error(err, string(debug.Stack())) 76 | } 77 | } 78 | 79 | func brokenDate() time.Time { 80 | return time.Date(1, 1, 0, 0, 0, 0, 0, time.UTC) 81 | } 82 | -------------------------------------------------------------------------------- /internal/charond/handler_list_group_permissions.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/session" 11 | "go.uber.org/zap" 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | type listGroupPermissionsHandler struct { 16 | *handler 17 | } 18 | 19 | func (lgph *listGroupPermissionsHandler) ListPermissions(ctx context.Context, req *charonrpc.ListGroupPermissionsRequest) (*charonrpc.ListGroupPermissionsResponse, error) { 20 | if req.Id <= 0 { 21 | return nil, grpcerr.E(codes.InvalidArgument, "missing group id") 22 | } 23 | 24 | act, err := lgph.Actor(ctx) 25 | if err != nil { 26 | return nil, err 27 | } 28 | if err = lgph.firewall(req, act); err != nil { 29 | return nil, err 30 | } 31 | 32 | permissions, err := lgph.repository.permission.FindByGroupID(ctx, req.Id) 33 | if err != nil { 34 | if err == sql.ErrNoRows { 35 | lgph.logger.Error("group permissions retrieved", zap.Int64("group_id", req.Id), zap.Int("count", len(permissions))) 36 | 37 | return &charonrpc.ListGroupPermissionsResponse{}, nil 38 | } 39 | return nil, grpcerr.E(codes.Internal, "find permissions by group id query failed", err) 40 | } 41 | 42 | perms := make([]string, 0, len(permissions)) 43 | for _, p := range permissions { 44 | perms = append(perms, p.Permission().String()) 45 | } 46 | 47 | return &charonrpc.ListGroupPermissionsResponse{ 48 | Permissions: perms, 49 | }, nil 50 | } 51 | 52 | func (lgph *listGroupPermissionsHandler) firewall(req *charonrpc.ListGroupPermissionsRequest, act *session.Actor) error { 53 | if act.User.IsSuperuser { 54 | return nil 55 | } 56 | if act.Permissions.Contains(charon.GroupPermissionCanRetrieve) { 57 | return nil 58 | } 59 | 60 | return grpcerr.E(codes.PermissionDenied, "list of group permissions cannot be retrieved, missing permission") 61 | } 62 | -------------------------------------------------------------------------------- /internal/charond/handler_create_group.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/piotrkowalczuk/charon" 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 9 | "github.com/piotrkowalczuk/charon/internal/mapping" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/session" 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | type createGroupHandler struct { 16 | *handler 17 | } 18 | 19 | func (cgh *createGroupHandler) Create(ctx context.Context, req *charonrpc.CreateGroupRequest) (*charonrpc.CreateGroupResponse, error) { 20 | if len(req.Name) < 3 { 21 | return nil, grpcerr.E(codes.InvalidArgument, "group name is required and needs to be at least 3 characters long") 22 | } 23 | act, err := cgh.Actor(ctx) 24 | if err != nil { 25 | return nil, err 26 | } 27 | if err = cgh.firewall(req, act); err != nil { 28 | return nil, err 29 | } 30 | 31 | ent, err := cgh.repository.group.Create(ctx, act.User.ID, req.Name, req.Description) 32 | if err != nil { 33 | switch model.ErrorConstraint(err) { 34 | case model.TableGroupConstraintNameUnique: 35 | return nil, grpcerr.E(codes.AlreadyExists, "group with given name already exists") 36 | default: 37 | return nil, grpcerr.E(codes.Internal, "group fetch failure", err) 38 | } 39 | } 40 | 41 | return cgh.response(ent) 42 | } 43 | 44 | func (cgh *createGroupHandler) firewall(req *charonrpc.CreateGroupRequest, act *session.Actor) error { 45 | if act.User.IsSuperuser { 46 | return nil 47 | } 48 | if act.Permissions.Contains(charon.GroupCanCreate) { 49 | return nil 50 | } 51 | 52 | return grpcerr.E(codes.PermissionDenied, "group cannot be created, missing permission") 53 | } 54 | 55 | func (cgh *createGroupHandler) response(ent *model.GroupEntity) (*charonrpc.CreateGroupResponse, error) { 56 | msg, err := mapping.ReverseGroup(ent) 57 | if err != nil { 58 | return nil, grpcerr.E(codes.Internal, "group entity mapping failure", err) 59 | } 60 | return &charonrpc.CreateGroupResponse{Group: msg}, nil 61 | } 62 | -------------------------------------------------------------------------------- /internal/charond/database.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/piotrkowalczuk/charon/internal/model" 8 | "github.com/piotrkowalczuk/charon/internal/password" 9 | ) 10 | 11 | type repositories struct { 12 | user model.UserProvider 13 | userGroups model.UserGroupsProvider 14 | userPermissions model.UserPermissionsProvider 15 | permission model.PermissionProvider 16 | group model.GroupProvider 17 | groupPermissions model.GroupPermissionsProvider 18 | refreshToken model.RefreshTokenProvider 19 | } 20 | 21 | func newRepositories(db *sql.DB) repositories { 22 | return repositories{ 23 | user: model.NewUserRepository(db), 24 | userGroups: model.NewUserGroupsRepository(db), 25 | userPermissions: model.NewUserPermissionsRepository(db), 26 | permission: model.NewPermissionRepository(db), 27 | group: model.NewGroupRepository(db), 28 | groupPermissions: model.NewGroupPermissionsRepository(db), 29 | refreshToken: model.NewRefreshTokenRepository(db), 30 | } 31 | } 32 | 33 | func execQueries(db *sql.DB, queries ...string) (err error) { 34 | exec := func(query string) { 35 | if err != nil { 36 | return 37 | } 38 | 39 | _, err = db.Exec(query) 40 | } 41 | 42 | for _, q := range queries { 43 | exec(q) 44 | } 45 | 46 | return 47 | } 48 | 49 | func setupDatabase(db *sql.DB) error { 50 | return execQueries( 51 | db, 52 | model.SQL, 53 | ) 54 | } 55 | 56 | func teardownDatabase(db *sql.DB) error { 57 | return execQueries( 58 | db, 59 | `DROP SCHEMA IF EXISTS charon CASCADE`, 60 | ) 61 | } 62 | 63 | func createDummyTestUser(ctx context.Context, usrProvider model.UserProvider, rftProvider model.RefreshTokenProvider, hasher password.Hasher) error { 64 | pass, err := hasher.Hash([]byte("test")) 65 | if err != nil { 66 | return err 67 | } 68 | usr, err := usrProvider.CreateSuperuser(ctx, "test", pass, "Test", "Test") 69 | if err != nil { 70 | return err 71 | } 72 | 73 | _, err = rftProvider.Create(ctx, &model.RefreshTokenEntity{Token: "test", UserID: usr.ID}) 74 | return err 75 | } 76 | -------------------------------------------------------------------------------- /pb/rpc/charond/v1/auth.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package charon.rpc.charond.v1; 4 | 5 | option go_package = "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1;charond"; 6 | option java_multiple_files = true; 7 | option java_package = "com.github.charon.rpc.charond.v1"; 8 | 9 | import "google/protobuf/empty.proto"; 10 | import "google/protobuf/wrappers.proto"; 11 | 12 | service Auth { 13 | rpc Login (LoginRequest) returns (google.protobuf.StringValue); 14 | rpc Logout (LogoutRequest) returns (google.protobuf.Empty); 15 | rpc IsAuthenticated (IsAuthenticatedRequest) returns (google.protobuf.BoolValue); 16 | rpc Actor (google.protobuf.StringValue) returns (ActorResponse); 17 | rpc IsGranted (IsGrantedRequest) returns (google.protobuf.BoolValue); 18 | rpc BelongsTo (BelongsToRequest) returns (google.protobuf.BoolValue); 19 | } 20 | 21 | message LoginRequest { 22 | string username = 1 [deprecated=true]; 23 | string password = 2 [deprecated=true]; 24 | string client = 3; 25 | reserved 13 to 19; 26 | oneof strategy { 27 | UsernameAndPasswordStrategy username_and_password = 11; 28 | RefreshTokenStrategy refresh_token = 12; 29 | } 30 | } 31 | 32 | message LogoutRequest { 33 | string access_token = 1; 34 | } 35 | 36 | message IsAuthenticatedRequest { 37 | string access_token = 1; 38 | } 39 | 40 | message IsGrantedRequest { 41 | int64 user_id = 1; 42 | string permission = 2; 43 | } 44 | 45 | message BelongsToRequest { 46 | int64 user_id = 1; 47 | int64 group_id = 2; 48 | } 49 | 50 | message ActorResponse { 51 | int64 id = 1; 52 | string username = 2; 53 | string first_name = 3; 54 | string last_name = 4; 55 | repeated string permissions = 5; 56 | bool is_superuser = 6; 57 | bool is_active = 7; 58 | bool is_stuff = 8 [deprecated = true]; 59 | bool is_confirmed = 9; 60 | bool is_staff = 10; 61 | } 62 | 63 | message UsernameAndPasswordStrategy { 64 | string username = 1; 65 | string password = 2; 66 | } 67 | 68 | message RefreshTokenStrategy { 69 | string refresh_token = 1; 70 | } 71 | -------------------------------------------------------------------------------- /internal/charond/handler_delete_group.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/golang/protobuf/ptypes/wrappers" 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/session" 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | type deleteGroupHandler struct { 16 | *handler 17 | } 18 | 19 | func (dgh *deleteGroupHandler) Delete(ctx context.Context, req *charonrpc.DeleteGroupRequest) (*wrappers.BoolValue, error) { 20 | if req.Id <= 0 { 21 | return nil, grpcerr.E(codes.InvalidArgument, "group cannot be deleted, invalid id") 22 | } 23 | 24 | act, err := dgh.Actor(ctx) 25 | if err != nil { 26 | return nil, err 27 | } 28 | if err = dgh.firewall(req, act); err != nil { 29 | return nil, err 30 | } 31 | 32 | aff, err := dgh.repository.group.DeleteOneByID(ctx, req.Id) 33 | if err != nil { 34 | //if err == sql.ErrNoRows { 35 | // return nil, grpcerr.E(codes.NotFound, "group deletion failure", err) 36 | //} 37 | switch model.ErrorConstraint(err) { 38 | case model.TableUserGroupsConstraintGroupIDForeignKey: 39 | return nil, grpcerr.E(codes.FailedPrecondition, "group cannot be removed, users are assigned to it") 40 | case model.TableGroupPermissionsConstraintGroupIDForeignKey: 41 | return nil, grpcerr.E(codes.FailedPrecondition, "group cannot be removed, permissions are assigned to it") 42 | default: 43 | return nil, grpcerr.E(codes.Internal, "group deletion failure", err) 44 | } 45 | } 46 | 47 | if aff == 0 { 48 | return nil, grpcerr.E(codes.NotFound, "group cannot be removed, does not exists") 49 | } 50 | 51 | return &wrappers.BoolValue{ 52 | Value: aff > 0, 53 | }, nil 54 | } 55 | 56 | func (dgh *deleteGroupHandler) firewall(req *charonrpc.DeleteGroupRequest, act *session.Actor) error { 57 | if act.User.IsSuperuser { 58 | return nil 59 | } 60 | if act.Permissions.Contains(charon.GroupCanDelete) { 61 | return nil 62 | } 63 | 64 | return grpcerr.E(codes.PermissionDenied, "group cannot be removed, missing permission") 65 | } 66 | -------------------------------------------------------------------------------- /internal/model/user_groups.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | // UserGroupsProvider ... 10 | type UserGroupsProvider interface { 11 | Insert(ctx context.Context, ent *UserGroupsEntity) (*UserGroupsEntity, error) 12 | Exists(ctx context.Context, userID, groupID int64) (bool, error) 13 | Find(ctx context.Context, expr *UserGroupsFindExpr) ([]*UserGroupsEntity, error) 14 | Set(ctx context.Context, userID int64, groupIDs []int64) (int64, int64, error) 15 | DeleteByUserID(ctx context.Context, id int64) (int64, error) 16 | } 17 | 18 | // UserGroupsRepository ... 19 | type UserGroupsRepository struct { 20 | UserGroupsRepositoryBase 21 | deleteByUserIDQuery string 22 | } 23 | 24 | // NewUserGroupsRepository ... 25 | func NewUserGroupsRepository(dbPool *sql.DB) UserGroupsProvider { 26 | return &UserGroupsRepository{ 27 | UserGroupsRepositoryBase: UserGroupsRepositoryBase{ 28 | DB: dbPool, 29 | Table: TableUserGroups, 30 | Columns: TableUserGroupsColumns, 31 | }, 32 | deleteByUserIDQuery: fmt.Sprintf("DELETE FROM %s WHERE %s = $1", TableUserGroups, TableUserGroupsColumnUserID), 33 | } 34 | } 35 | 36 | // Exists implements UserGroupsProvider interface. 37 | func (ugr *UserGroupsRepository) Exists(ctx context.Context, userID, groupID int64) (bool, error) { 38 | var exists bool 39 | if err := ugr.DB.QueryRowContext(ctx, existsManyToManyQuery(ugr.Table, TableUserGroupsColumnUserID, TableUserGroupsColumnGroupID), userID, groupID).Scan(&exists); err != nil { 40 | return false, err 41 | } 42 | 43 | return exists, nil 44 | } 45 | 46 | // Set implements UserGroupsProvider interface. 47 | func (ugr *UserGroupsRepository) Set(ctx context.Context, userID int64, groupIDs []int64) (int64, int64, error) { 48 | return setManyToMany(ugr.DB, ctx, ugr.Table, TableUserGroupsColumnUserID, TableUserGroupsColumnGroupID, userID, groupIDs) 49 | } 50 | 51 | // DeleteByUserID removes user from all groups he belongs to. 52 | func (ugr *UserGroupsRepository) DeleteByUserID(ctx context.Context, id int64) (int64, error) { 53 | res, err := ugr.DB.ExecContext(ctx, ugr.deleteByUserIDQuery, id) 54 | if err != nil { 55 | return 0, err 56 | } 57 | return res.RowsAffected() 58 | } 59 | -------------------------------------------------------------------------------- /internal/charond/handler_list_refresh_tokens.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "github.com/piotrkowalczuk/charon" 5 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 6 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 7 | "github.com/piotrkowalczuk/charon/internal/mapping" 8 | "github.com/piotrkowalczuk/charon/internal/model" 9 | "github.com/piotrkowalczuk/charon/internal/session" 10 | "github.com/piotrkowalczuk/qtypes" 11 | "golang.org/x/net/context" 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | type listRefreshTokensHandler struct { 16 | *handler 17 | } 18 | 19 | func (lrth *listRefreshTokensHandler) List(ctx context.Context, req *charonrpc.ListRefreshTokensRequest) (*charonrpc.ListRefreshTokensResponse, error) { 20 | act, err := lrth.Actor(ctx) 21 | if err != nil { 22 | return nil, err 23 | } 24 | if err = lrth.firewall(req, act); err != nil { 25 | return nil, err 26 | } 27 | 28 | ents, err := lrth.repository.refreshToken.Find(ctx, &model.RefreshTokenFindExpr{ 29 | Limit: req.GetLimit().Int64Or(10), 30 | Offset: req.GetOffset().Int64Or(0), 31 | OrderBy: mapping.OrderBy(req.GetOrderBy()), 32 | Where: mapping.RefreshTokenQuery(req.GetQuery()), 33 | }) 34 | if err != nil { 35 | return nil, grpcerr.E(codes.Internal, "find refresh token query failed", err) 36 | } 37 | 38 | msg, err := mapping.ReverseRefreshTokens(ents) 39 | if err != nil { 40 | return nil, grpcerr.E(codes.Internal, "refresh token reverse mapping failure") 41 | } 42 | return &charonrpc.ListRefreshTokensResponse{ 43 | RefreshTokens: msg, 44 | }, nil 45 | } 46 | 47 | func (lrth *listRefreshTokensHandler) firewall(req *charonrpc.ListRefreshTokensRequest, act *session.Actor) error { 48 | if act.User.IsSuperuser { 49 | return nil 50 | } 51 | if act.Permissions.Contains(charon.RefreshTokenCanRetrieveAsStranger) { 52 | return nil 53 | } 54 | if act.Permissions.Contains(charon.RefreshTokenCanRetrieveAsOwner) { 55 | if req.Query == nil { 56 | req.Query = &charonrpc.RefreshTokenQuery{} 57 | } 58 | req.Query.UserId = qtypes.EqualInt64(act.User.ID) 59 | return nil 60 | } 61 | 62 | return grpcerr.E(codes.PermissionDenied, "list of refresh tokens cannot be retrieved, missing permission") 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Charon [![CircleCI](https://circleci.com/gh/piotrkowalczuk/charon/tree/master.svg?style=svg)](https://circleci.com/gh/piotrkowalczuk/charon/tree/master) 2 | 3 | [![GoDoc](https://godoc.org/github.com/piotrkowalczuk/charon?status.svg)](http://godoc.org/github.com/piotrkowalczuk/charon) 4 | [![Test Coverage](https://api.codeclimate.com/v1/badges/de987e80be49eba8fb61/test_coverage)](https://codeclimate.com/github/piotrkowalczuk/charon/test_coverage) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/de987e80be49eba8fb61/maintainability)](https://codeclimate.com/github/piotrkowalczuk/charon/maintainability) 6 | [![Docker Pulls](https://img.shields.io/docker/pulls/piotrkowalczuk/charon.svg?maxAge=604800)](https://hub.docker.com/r/piotrkowalczuk/charon/) 7 | [![pypi](https://img.shields.io/pypi/v/charon-client.svg)](https://pypi.python.org/pypi/charon-client) 8 | 9 | 10 | 11 | ## Quick Start 12 | 13 | ### Installation 14 | 15 | ```bash 16 | $ go install github.com/piotrkowalczuk/charon/cmd/charond 17 | $ go install github.com/piotrkowalczuk/charon/cmd/charonctl 18 | ``` 19 | 20 | ### Superuser 21 | 22 | ```bash 23 | $ charonctl register -address=localhost:8080 -auth.disabled -register.superuser=true -register.username="j.snow@gmail.com" -register.password=123 -register.firstname=John -register.lastname=Snow 24 | ``` 25 | ## Example 26 | 27 | TODO 28 | 29 | ## Contribution 30 | 31 | @TODO 32 | 33 | ### Documentation 34 | 35 | @TODO 36 | 37 | ### TODO 38 | - [x] Auth 39 | - [x] login 40 | - [x] logout 41 | - [x] is authenticated 42 | - [x] subject 43 | - [x] is granted 44 | - [x] belongs to 45 | - [x] Permission 46 | - [x] get 47 | - [x] list 48 | - [x] register 49 | - [x] Group 50 | - [x] get 51 | - [x] list 52 | - [x] modify 53 | - [x] delete 54 | - [x] create 55 | - [x] set permissions 56 | - [x] list permissions 57 | - [x] User 58 | - [x] get 59 | - [x] list 60 | - [x] modify 61 | - [x] delete 62 | - [x] create 63 | - [x] set permissions 64 | - [x] set groups 65 | - [x] list permissions 66 | - [x] list groups 67 | - [x] Refresh Token 68 | - [x] Create 69 | - [x] Revoke 70 | - [x] List 71 | -------------------------------------------------------------------------------- /internal/charond/handler_set_user_permissions.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lib/pq" 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/session" 12 | 13 | "google.golang.org/grpc/codes" 14 | ) 15 | 16 | type setUserPermissionsHandler struct { 17 | *handler 18 | } 19 | 20 | func (suph *setUserPermissionsHandler) SetPermissions(ctx context.Context, req *charonrpc.SetUserPermissionsRequest) (*charonrpc.SetUserPermissionsResponse, error) { 21 | act, err := suph.Actor(ctx) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | if err = suph.firewall(req, act); err != nil { 27 | return nil, err 28 | } 29 | 30 | permissions := charon.NewPermissions(req.Permissions...) 31 | if req.Force { 32 | _, err := suph.repository.permission.InsertMissing(ctx, permissions) 33 | if err != nil { 34 | return nil, err 35 | } 36 | } 37 | 38 | created, removed, err := suph.repository.user.SetPermissions(ctx, req.UserId, permissions...) 39 | if err != nil { 40 | switch model.ErrorConstraint(err) { 41 | case model.TableUserPermissionsConstraintUserIDForeignKey: 42 | return nil, grpcerr.E(codes.NotFound, "%s: user does not exist", err.(*pq.Error).Detail) 43 | case model.TableUserPermissionsConstraintPermissionSubsystemPermissionModulePermissionActionForeignKey: 44 | return nil, grpcerr.E(codes.NotFound, "%s: permission does not exist", err.(*pq.Error).Detail) 45 | default: 46 | return nil, err 47 | } 48 | } 49 | 50 | return &charonrpc.SetUserPermissionsResponse{ 51 | Created: created, 52 | Removed: removed, 53 | Untouched: untouched(int64(len(req.Permissions)), created, removed), 54 | }, nil 55 | } 56 | 57 | func (suph *setUserPermissionsHandler) firewall(req *charonrpc.SetUserPermissionsRequest, act *session.Actor) error { 58 | if act.User.IsSuperuser { 59 | return nil 60 | } 61 | 62 | if act.Permissions.Contains(charon.UserPermissionCanCreate) && act.Permissions.Contains(charon.UserPermissionCanDelete) { 63 | return nil 64 | } 65 | 66 | return grpcerr.E(codes.PermissionDenied, "user permissions cannot be set, missing permission") 67 | } 68 | -------------------------------------------------------------------------------- /internal/charonctl/console_register_user.go: -------------------------------------------------------------------------------- 1 | package charonctl 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/piotrkowalczuk/charon" 9 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 10 | "github.com/piotrkowalczuk/ntypes" 11 | "google.golang.org/grpc/codes" 12 | "google.golang.org/grpc/status" 13 | ) 14 | 15 | type RegisterUserArg struct { 16 | IfNotExists bool 17 | Username string 18 | Password string 19 | FirstName string 20 | LastName string 21 | Superuser bool 22 | Confirmed bool 23 | Staff bool 24 | Active bool 25 | Permissions charon.Permissions 26 | } 27 | 28 | type consoleRegisterUser struct { 29 | user charonrpc.UserManagerClient 30 | } 31 | 32 | func (cru *consoleRegisterUser) RegisterUser(ctx context.Context, arg *RegisterUserArg) error { 33 | res, err := cru.user.Create(ctx, &charonrpc.CreateUserRequest{ 34 | Username: arg.Username, 35 | PlainPassword: arg.Password, 36 | FirstName: arg.FirstName, 37 | LastName: arg.LastName, 38 | IsSuperuser: &ntypes.Bool{Bool: arg.Superuser, Valid: true}, 39 | IsConfirmed: &ntypes.Bool{Bool: arg.Confirmed, Valid: true}, 40 | IsStaff: &ntypes.Bool{Bool: arg.Staff, Valid: true}, 41 | IsActive: &ntypes.Bool{Bool: arg.Active, Valid: true}, 42 | }) 43 | if err != nil { 44 | if arg.IfNotExists && status.Code(err) == codes.AlreadyExists { 45 | return &Error{ 46 | Msg: fmt.Sprintf("user already exists: %s\n", arg.Username), 47 | Err: err, 48 | } 49 | } 50 | return &Error{ 51 | Msg: "registration failure", 52 | Err: err, 53 | } 54 | } 55 | 56 | if arg.Superuser { 57 | fmt.Printf("superuser has been created: %s\n", res.User.Username) 58 | } else { 59 | fmt.Printf("user has been created: %s\n", res.User.Username) 60 | } 61 | 62 | if len(arg.Permissions) > 0 { 63 | if arg.Superuser { 64 | // superuser does not need permissions 65 | os.Exit(0) 66 | } 67 | 68 | if _, err = cru.user.SetPermissions(ctx, &charonrpc.SetUserPermissionsRequest{ 69 | UserId: res.User.Id, 70 | Permissions: arg.Permissions.Strings(), 71 | }); err != nil { 72 | return &Error{ 73 | Msg: "permission assigment failure", 74 | Err: err, 75 | } 76 | } 77 | 78 | fmt.Println("users permissions has been set") 79 | } 80 | return nil 81 | } 82 | -------------------------------------------------------------------------------- /internal/charond/handler_actor.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/golang/protobuf/ptypes/wrappers" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/session" 11 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | type actorHandler struct { 16 | *handler 17 | } 18 | 19 | func (sh *actorHandler) Actor(ctx context.Context, r *wrappers.StringValue) (*charonrpc.ActorResponse, error) { 20 | var ses *mnemosynerpc.Session 21 | 22 | if r.Value == "" { 23 | res, err := sh.session.Context(ctx, none()) 24 | if err != nil { 25 | return nil, handleMnemosyneError(err) 26 | } 27 | ses = res.Session 28 | } else { 29 | res, err := sh.session.Get(ctx, &mnemosynerpc.GetRequest{ 30 | AccessToken: r.Value, 31 | }) 32 | if err != nil { 33 | return nil, handleMnemosyneError(err) 34 | } 35 | ses = res.Session 36 | } 37 | 38 | id, err := session.ActorID(ses.SubjectId).UserID() 39 | if err != nil { 40 | return nil, grpcerr.E(codes.Internal, "invalid session actor id") 41 | } 42 | 43 | ent, err := sh.repository.user.FindOneByID(ctx, id) 44 | switch err { 45 | case nil: 46 | case sql.ErrNoRows: 47 | return nil, grpcerr.E(codes.NotFound, "actor does not exists for given id") 48 | default: 49 | return nil, grpcerr.E(codes.Internal, "actor retrieval failure", err) 50 | } 51 | 52 | permissionEntities, err := sh.repository.permission.FindByUserID(ctx, id) 53 | switch err { 54 | case nil, sql.ErrNoRows: 55 | default: 56 | return nil, grpcerr.E(codes.Internal, "actor list of permissions failure", err) 57 | } 58 | 59 | permissions := make([]string, 0, len(permissionEntities)) 60 | for _, e := range permissionEntities { 61 | permissions = append(permissions, e.Permission().String()) 62 | } 63 | 64 | return &charonrpc.ActorResponse{ 65 | Id: int64(ent.ID), 66 | Username: ent.Username, 67 | FirstName: ent.FirstName, 68 | LastName: ent.LastName, 69 | Permissions: permissions, 70 | IsActive: ent.IsActive, 71 | IsConfirmed: ent.IsConfirmed, 72 | IsStuff: ent.IsStaff, 73 | IsStaff: ent.IsStaff, 74 | IsSuperuser: ent.IsSuperuser, 75 | }, nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/charond/handler_set_group_permissions.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/lib/pq" 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/session" 12 | 13 | "google.golang.org/grpc/codes" 14 | ) 15 | 16 | type setGroupPermissionsHandler struct { 17 | *handler 18 | } 19 | 20 | func (sgph *setGroupPermissionsHandler) SetPermissions(ctx context.Context, req *charonrpc.SetGroupPermissionsRequest) (*charonrpc.SetGroupPermissionsResponse, error) { 21 | act, err := sgph.Actor(ctx) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | if err = sgph.firewall(req, act); err != nil { 27 | return nil, err 28 | } 29 | 30 | permissions := charon.NewPermissions(req.Permissions...) 31 | if req.Force { 32 | _, err := sgph.repository.permission.InsertMissing(ctx, permissions) 33 | if err != nil { 34 | return nil, err 35 | } 36 | } 37 | 38 | created, removed, err := sgph.repository.group.SetPermissions(ctx, req.GroupId, permissions...) 39 | if err != nil { 40 | switch model.ErrorConstraint(err) { 41 | case model.TableGroupPermissionsConstraintGroupIDForeignKey: 42 | return nil, grpcerr.E(codes.NotFound, "%s: group does not exist", err.(*pq.Error).Detail) 43 | case model.TableGroupPermissionsConstraintPermissionSubsystemPermissionModulePermissionActionForeignKey: 44 | return nil, grpcerr.E(codes.NotFound, "%s: permission does not exist", err.(*pq.Error).Detail) 45 | default: 46 | return nil, err 47 | } 48 | } 49 | 50 | return &charonrpc.SetGroupPermissionsResponse{ 51 | Created: created, 52 | Removed: removed, 53 | Untouched: untouched(int64(len(req.Permissions)), created, removed), 54 | }, nil 55 | } 56 | 57 | func (sgph *setGroupPermissionsHandler) firewall(req *charonrpc.SetGroupPermissionsRequest, act *session.Actor) error { 58 | if act.User.IsSuperuser { 59 | return nil 60 | } 61 | if act.Permissions.Contains(charon.GroupPermissionCanCreate) && act.Permissions.Contains(charon.GroupPermissionCanDelete) { 62 | return nil 63 | } 64 | 65 | return grpcerr.E(codes.PermissionDenied, "group permissions cannot be set, missing permission") 66 | } 67 | -------------------------------------------------------------------------------- /pb/rpc/charond/v1/refresh_token.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package charon.rpc.charond.v1; 4 | 5 | option go_package = "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1;charond"; 6 | option java_multiple_files = true; 7 | option java_package = "com.github.charon.rpc.charond.v1"; 8 | 9 | import "google/protobuf/timestamp.proto"; 10 | import "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1/common.proto"; 11 | import "qtypes/qtypes.proto"; 12 | import "ntypes/ntypes.proto"; 13 | 14 | service RefreshTokenManager { 15 | rpc Create(CreateRefreshTokenRequest) returns (CreateRefreshTokenResponse) {}; 16 | rpc Revoke(RevokeRefreshTokenRequest) returns (RevokeRefreshTokenResponse) {}; 17 | rpc List(ListRefreshTokensRequest) returns (ListRefreshTokensResponse) {}; 18 | } 19 | 20 | message RefreshToken { 21 | string token = 1; 22 | ntypes.String notes = 2; 23 | int64 user_id = 3; 24 | bool revoked = 4; 25 | google.protobuf.Timestamp expire_at = 5; 26 | google.protobuf.Timestamp last_used_at = 6; 27 | google.protobuf.Timestamp created_at = 7; 28 | ntypes.Int64 created_by = 8; 29 | google.protobuf.Timestamp updated_at = 9; 30 | ntypes.Int64 updated_by = 10; 31 | } 32 | 33 | message RefreshTokenQuery { 34 | qtypes.Int64 user_id = 1; 35 | qtypes.String notes = 2; 36 | ntypes.Bool revoked = 3; 37 | qtypes.Timestamp expire_at = 4; 38 | qtypes.Timestamp last_used_at = 5; 39 | qtypes.Timestamp created_at = 6; 40 | qtypes.Timestamp updated_at = 7; 41 | } 42 | 43 | message CreateRefreshTokenRequest { 44 | ntypes.String notes = 1; 45 | google.protobuf.Timestamp expire_at = 2; 46 | } 47 | 48 | message CreateRefreshTokenResponse { 49 | RefreshToken refresh_token = 1; 50 | } 51 | 52 | message ListRefreshTokensRequest { 53 | ntypes.Int64 offset = 1; 54 | ntypes.Int64 limit = 2; 55 | repeated Order order_by = 3; 56 | reserved 4 to 10; 57 | 58 | RefreshTokenQuery query = 11; 59 | } 60 | 61 | message ListRefreshTokensResponse { 62 | repeated RefreshToken refresh_tokens = 1; 63 | } 64 | 65 | message RevokeRefreshTokenRequest { 66 | string token = 1; 67 | int64 user_id = 2; 68 | } 69 | 70 | message RevokeRefreshTokenResponse { 71 | RefreshToken refresh_token = 1; 72 | } 73 | -------------------------------------------------------------------------------- /internal/mapping/refresh_token.go: -------------------------------------------------------------------------------- 1 | package mapping 2 | 3 | import ( 4 | "github.com/golang/protobuf/ptypes" 5 | pbts "github.com/golang/protobuf/ptypes/timestamp" 6 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 7 | "github.com/piotrkowalczuk/charon/internal/model" 8 | "github.com/piotrkowalczuk/ntypes" 9 | ) 10 | 11 | func ReverseRefreshToken(ent *model.RefreshTokenEntity) (*charonrpc.RefreshToken, error) { 12 | var ( 13 | err error 14 | expireAt, lastUsedAt, createdAt, updatedAt *pbts.Timestamp 15 | ) 16 | 17 | if createdAt, err = ptypes.TimestampProto(ent.CreatedAt); err != nil { 18 | return nil, err 19 | } 20 | if ent.UpdatedAt.Valid { 21 | if updatedAt, err = ptypes.TimestampProto(ent.UpdatedAt.Time); err != nil { 22 | return nil, err 23 | } 24 | } 25 | if ent.ExpireAt.Valid { 26 | if expireAt, err = ptypes.TimestampProto(ent.ExpireAt.Time); err != nil { 27 | return nil, err 28 | } 29 | } 30 | if ent.LastUsedAt.Valid { 31 | if lastUsedAt, err = ptypes.TimestampProto(ent.LastUsedAt.Time); err != nil { 32 | return nil, err 33 | } 34 | } 35 | 36 | return &charonrpc.RefreshToken{ 37 | Token: ent.Token, 38 | Notes: &ent.Notes, 39 | Revoked: ent.Revoked, 40 | ExpireAt: expireAt, 41 | LastUsedAt: lastUsedAt, 42 | UserId: ent.UserID, 43 | CreatedAt: createdAt, 44 | CreatedBy: &ent.CreatedBy, 45 | UpdatedAt: updatedAt, 46 | UpdatedBy: &ent.UpdatedBy, 47 | }, nil 48 | } 49 | 50 | func ReverseRefreshTokens(in []*model.RefreshTokenEntity) ([]*charonrpc.RefreshToken, error) { 51 | res := make([]*charonrpc.RefreshToken, 0, len(in)) 52 | for _, ent := range in { 53 | msg, err := ReverseRefreshToken(ent) 54 | if err != nil { 55 | return nil, err 56 | } 57 | res = append(res, msg) 58 | } 59 | 60 | return res, nil 61 | } 62 | 63 | func RefreshTokenQuery(q *charonrpc.RefreshTokenQuery) *model.RefreshTokenCriteria { 64 | var revoked ntypes.Bool 65 | if q.GetRevoked() != nil { 66 | revoked = *q.GetRevoked() 67 | } 68 | 69 | return &model.RefreshTokenCriteria{ 70 | UserID: q.GetUserId(), 71 | Notes: q.GetNotes(), 72 | Revoked: revoked, // TODO: pointer? 73 | ExpireAt: q.GetExpireAt(), 74 | LastUsedAt: q.GetLastUsedAt(), 75 | CreatedAt: q.GetCreatedAt(), 76 | UpdatedAt: q.GetUpdatedAt(), 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /internal/charond/handler_modify_group.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/mapping" 11 | "github.com/piotrkowalczuk/charon/internal/model" 12 | "github.com/piotrkowalczuk/charon/internal/session" 13 | "github.com/piotrkowalczuk/ntypes" 14 | "google.golang.org/grpc/codes" 15 | ) 16 | 17 | type modifyGroupHandler struct { 18 | *handler 19 | } 20 | 21 | func (mgh *modifyGroupHandler) Modify(ctx context.Context, req *charonrpc.ModifyGroupRequest) (*charonrpc.ModifyGroupResponse, error) { 22 | if req.Id <= 0 { 23 | return nil, grpcerr.E(codes.InvalidArgument, "group id is missing") 24 | } 25 | if !req.GetName().GetValid() && !req.GetDescription().GetValid() { 26 | return nil, grpcerr.E(codes.InvalidArgument, "nothing to be modified") 27 | } 28 | act, err := mgh.Actor(ctx) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | if err = mgh.firewall(req, act); err != nil { 34 | return nil, err 35 | } 36 | 37 | group, err := mgh.repository.group.UpdateOneByID(ctx, req.Id, &model.GroupPatch{ 38 | UpdatedBy: ntypes.Int64{Int64: act.User.ID, Valid: true}, 39 | Name: allocNilString(req.Name), 40 | Description: allocNilString(req.Description), 41 | }) 42 | if err != nil { 43 | if err == sql.ErrNoRows { 44 | return nil, grpcerr.E(codes.NotFound, "group does not exists") 45 | } 46 | return nil, grpcerr.E(codes.Internal, "update group by id query failed", err) 47 | } 48 | 49 | return mgh.response(group) 50 | } 51 | 52 | func (mgh *modifyGroupHandler) firewall(req *charonrpc.ModifyGroupRequest, act *session.Actor) error { 53 | if act.User.IsSuperuser { 54 | return nil 55 | } 56 | if act.Permissions.Contains(charon.GroupCanModify) { 57 | return nil 58 | } 59 | 60 | return grpcerr.E(codes.PermissionDenied, "group cannot be modified, missing permission") 61 | } 62 | 63 | func (mgh *modifyGroupHandler) response(ent *model.GroupEntity) (*charonrpc.ModifyGroupResponse, error) { 64 | msg, err := mapping.ReverseGroup(ent) 65 | if err != nil { 66 | return nil, grpcerr.E(codes.Internal, "group reverse mapping failure") 67 | } 68 | return &charonrpc.ModifyGroupResponse{Group: msg}, nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/securitycontext/security_context.go: -------------------------------------------------------------------------------- 1 | package securitycontext 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/piotrkowalczuk/charon" 8 | "github.com/piotrkowalczuk/mnemosyne" 9 | "golang.org/x/oauth2" 10 | ) 11 | 12 | // Context .... 13 | type Context interface { 14 | context.Context 15 | oauth2.TokenSource 16 | // Actor ... 17 | Actor() (Actor, bool) 18 | // AccessToken ... 19 | AccessToken() (string, bool) 20 | } 21 | 22 | type securityContext struct { 23 | context.Context 24 | } 25 | 26 | // NewSecurityContext allocates new context. 27 | func NewSecurityContext(ctx context.Context) Context { 28 | return &securityContext{Context: ctx} 29 | } 30 | 31 | // Actor implements Context interface. 32 | func (sc *securityContext) Actor() (Actor, bool) { 33 | return ActorFromContext(sc) 34 | } 35 | 36 | // AccessToken implements Context interface. 37 | func (sc *securityContext) AccessToken() (string, bool) { 38 | return mnemosyne.AccessTokenFromContext(sc.Context) 39 | } 40 | 41 | // Token implements oauth2.TokenSource interface. 42 | func (sc *securityContext) Token() (*oauth2.Token, error) { 43 | at, ok := sc.AccessToken() 44 | if !ok { 45 | return nil, errors.New("securitycontext: missing access token, oauth2 token cannot be returned") 46 | } 47 | return &oauth2.Token{ 48 | AccessToken: at, 49 | }, nil 50 | } 51 | 52 | // Actor is a generic object that represent anything that can be under control of charon. 53 | type Actor struct { 54 | ID int64 `json:"id"` 55 | Username string `json:"username"` 56 | FirstName string `json:"firstName"` 57 | LastName string `json:"lastName"` 58 | IsSuperuser bool `json:"isSuperuser"` 59 | IsActive bool `json:"isActive"` 60 | IsStaff bool `json:"isStaff"` 61 | IsConfirmed bool `json:"isConfirmed"` 62 | Permissions charon.Permissions `json:"permissions"` 63 | } 64 | 65 | type key struct{} 66 | 67 | var contextKeyActor = key{} 68 | 69 | // NewActorContext returns a new Context that carries Actor value. 70 | func NewActorContext(ctx context.Context, act Actor) context.Context { 71 | return context.WithValue(ctx, contextKeyActor, act) 72 | } 73 | 74 | // ActorFromContext returns the Actor value stored in context, if any. 75 | func ActorFromContext(ctx context.Context) (Actor, bool) { 76 | act, ok := ctx.Value(contextKeyActor).(Actor) 77 | return act, ok 78 | } 79 | -------------------------------------------------------------------------------- /internal/charond/daemon_test.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | 7 | "go.uber.org/zap" 8 | 9 | "github.com/piotrkowalczuk/mnemosyne/mnemosyned" 10 | "golang.org/x/crypto/bcrypt" 11 | ) 12 | 13 | func TestDaemon_Run(t *testing.T) { 14 | certPath := "../../data/test-selfsigned.crt" 15 | keyPath := "../../data/test-selfsigned.key" 16 | 17 | mnemosyneRPCListener, err := net.Listen("tcp", "localhost:0") // any available address 18 | if err != nil { 19 | t.Fatalf("mnemosyne daemon tcp listener setup error: %s", err.Error()) 20 | } 21 | mnemosyneDaemon, err := mnemosyned.NewDaemon(&mnemosyned.DaemonOpts{ 22 | IsTest: true, 23 | ClusterListenAddr: mnemosyneRPCListener.Addr().String(), 24 | Logger: zap.L(), 25 | PostgresAddress: testPostgresAddress, 26 | PostgresTable: "session", 27 | PostgresSchema: "mnemosyne", 28 | RPCListener: mnemosyneRPCListener, 29 | TLS: true, 30 | TLSKeyFile: keyPath, 31 | TLSCertFile: certPath, 32 | }) 33 | if err != nil { 34 | t.Fatalf("mnemosyne daemon cannot be instantiated: %s", err.Error()) 35 | } 36 | if err := mnemosyneDaemon.Run(); err != nil { 37 | t.Fatalf("mnemosyne daemon start error: %s", err.Error()) 38 | } 39 | 40 | charonRPCListener, err := net.Listen("tcp", "localhost:0") // any available address 41 | if err != nil { 42 | t.Fatalf("charon daemon tcp listener setup error: %s", err.Error()) 43 | } 44 | debugRPCListener, err := net.Listen("tcp", "localhost:0") // any available address 45 | if err != nil { 46 | t.Fatalf("charon daemon tcp listener setup error: %s", err.Error()) 47 | } 48 | 49 | logger := zap.L() 50 | 51 | d := NewDaemon(DaemonOpts{ 52 | Test: true, 53 | Monitoring: false, 54 | MnemosyneAddress: mnemosyneDaemon.Addr().String(), 55 | MnemosyneTLS: true, 56 | MnemosyneTLSCertFile: certPath, 57 | Logger: logger, 58 | PostgresAddress: testPostgresAddress, 59 | RPCListener: charonRPCListener, 60 | DebugListener: debugRPCListener, 61 | PasswordBCryptCost: bcrypt.MinCost, 62 | TLS: true, 63 | TLSKeyFile: keyPath, 64 | TLSCertFile: certPath, 65 | }) 66 | if err := d.Run(); err != nil { 67 | t.Fatalf("charon daemon start error: %s", err.Error()) 68 | } 69 | if err := d.Close(); err != nil { 70 | t.Fatalf("charon daemon close error: %s", err.Error()) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /example/client/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "time" 9 | 10 | "github.com/golang/protobuf/ptypes/wrappers" 11 | "github.com/piotrkowalczuk/charon" 12 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 13 | "google.golang.org/grpc" 14 | "google.golang.org/grpc/metadata" 15 | "google.golang.org/grpc/status" 16 | ) 17 | 18 | const ( 19 | permissionCommentCanCreate charon.Permission = "forumservice:comment:can create" 20 | permissionCommentCanEditAsOwner charon.Permission = "forumservice:comment:can edit as an owner" 21 | permissionCommentCanEditAsStranger charon.Permission = "forumservice:comment:can edit as a stranger" 22 | ) 23 | 24 | var ( 25 | address string 26 | token string 27 | ) 28 | 29 | func init() { 30 | flag.StringVar(&address, "address", "localhost:8010", "charond service address") 31 | flag.StringVar(&token, "token", "", "session token") 32 | } 33 | 34 | func main() { 35 | flag.Parse() 36 | 37 | if token == "" { 38 | log.Fatal("missing session token") 39 | } 40 | 41 | conn, err := grpc.Dial(address, grpc.WithBlock(), grpc.WithInsecure(), grpc.WithTimeout(2*time.Second)) 42 | if err != nil { 43 | log.Fatal(status.Convert(err).Message()) 44 | } 45 | defer conn.Close() 46 | 47 | rpc := charonrpc.NewPermissionManagerClient(conn) 48 | if _, err = rpc.Register(context.Background(), &charonrpc.RegisterPermissionsRequest{ 49 | Permissions: []string{ 50 | permissionCommentCanCreate.String(), 51 | permissionCommentCanEditAsOwner.String(), 52 | permissionCommentCanEditAsStranger.String(), 53 | }, 54 | }); err != nil { 55 | log.Fatal(err) 56 | } 57 | 58 | ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("request_id", "123456789")) 59 | res, err := charonrpc.NewAuthClient(conn).Actor(ctx, &wrappers.StringValue{Value: token}) 60 | if err != nil { 61 | log.Fatalf("%s: %s", status.Code(err).String(), status.Convert(err).Message()) 62 | } 63 | 64 | fmt.Printf("id: %d \n", res.Id) 65 | fmt.Printf("username: %s \n", res.Username) 66 | fmt.Printf("first name: %s \n", res.FirstName) 67 | fmt.Printf("last name: %s \n", res.LastName) 68 | fmt.Printf("is active: %t \n", res.IsActive) 69 | fmt.Printf("is confirmed: %t \n", res.IsConfirmed) 70 | fmt.Printf("is staff: %t \n", res.IsStuff) 71 | fmt.Printf("is superuser: %t \n", res.IsSuperuser) 72 | if len(res.Permissions) > 0 { 73 | fmt.Println("permissions:") 74 | } 75 | for _, p := range res.Permissions { 76 | fmt.Printf(" - %s \n", p) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | 23 | ignored = ["github.com/piotrkowalczuk/charon/data"] 24 | 25 | [[constraint]] 26 | name = "github.com/golang/protobuf" 27 | version = "^1.2.0" 28 | 29 | [[constraint]] 30 | name = "github.com/lib/pq" 31 | version = "^1.0.0" 32 | 33 | [[constraint]] 34 | name = "github.com/google/uuid" 35 | version = "^1.0.0" 36 | 37 | [[constraint]] 38 | name = "github.com/piotrkowalczuk/zapstackdriver" 39 | version = "^1.0.0" 40 | 41 | [[constraint]] 42 | name = "github.com/piotrkowalczuk/mnemosyne" 43 | version = "~0.18.0" 44 | 45 | [[constraint]] 46 | name = "github.com/piotrkowalczuk/ntypes" 47 | version = "^1.0.0" 48 | 49 | [[constraint]] 50 | name = "github.com/piotrkowalczuk/promgrpc" 51 | version = "^3.2.0" 52 | 53 | [[constraint]] 54 | name = "github.com/piotrkowalczuk/qtypes" 55 | version = "~0.3.6" 56 | 57 | [[constraint]] 58 | name = "github.com/prometheus/client_golang" 59 | version = "~0.8.0" 60 | 61 | [[constraint]] 62 | name = "github.com/smartystreets/goconvey" 63 | version = "^1.6.2" 64 | 65 | [[constraint]] 66 | name = "github.com/stretchr/testify" 67 | version = "^1.1.4" 68 | 69 | [[constraint]] 70 | name = "go.uber.org/zap" 71 | version = "^1.0.0" 72 | 73 | [[constraint]] 74 | name = "golang.org/x/crypto" 75 | branch = "master" 76 | 77 | [[constraint]] 78 | name = "golang.org/x/net" 79 | branch = "master" 80 | 81 | [[constraint]] 82 | name = "golang.org/x/oauth2" 83 | branch = "master" 84 | 85 | [[constraint]] 86 | name = "google.golang.org/grpc" 87 | version = "^1.16.0" 88 | 89 | [[constraint]] 90 | name = "github.com/piotrkowalczuk/pqt" 91 | version = "~0.27.0" 92 | 93 | [[constraint]] 94 | name = "github.com/piotrkowalczuk/qtypespqt" 95 | version = "~0.4.0" 96 | 97 | [[constraint]] 98 | name = "github.com/piotrkowalczuk/ntypespqt" 99 | version = "~0.2.6" 100 | -------------------------------------------------------------------------------- /pkg/securitycontext/security_context_test.go: -------------------------------------------------------------------------------- 1 | package securitycontext 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/piotrkowalczuk/mnemosyne" 10 | "golang.org/x/oauth2" 11 | ) 12 | 13 | func ExampleSecurityContext() { 14 | token := "0000000001some hash" 15 | subject := Actor{ 16 | ID: 1, 17 | Username: "j.kowalski@gmail.com", 18 | } 19 | ctx := NewActorContext(context.Background(), subject) 20 | ctx = mnemosyne.NewAccessTokenContext(ctx, token) 21 | sctx := NewSecurityContext(ctx) 22 | 23 | var ( 24 | t *oauth2.Token 25 | act Actor 26 | err error 27 | ok bool 28 | ) 29 | if t, err = sctx.Token(); err != nil { 30 | fmt.Printf("unexpected error: %s", err.Error()) 31 | } else { 32 | fmt.Println(t.AccessToken) 33 | } 34 | if act, ok = sctx.Actor(); ok { 35 | fmt.Println(act.ID) 36 | fmt.Println(act.Username) 37 | } 38 | 39 | // Output: 40 | // 0000000001some hash 41 | // 1 42 | // j.kowalski@gmail.com 43 | } 44 | 45 | func TestNewSecurityContext(t *testing.T) { 46 | sctx := NewSecurityContext(context.Background()) 47 | 48 | if _, ok := sctx.(Context); !ok { 49 | t.Error("result should imeplement Context interface") 50 | } 51 | } 52 | 53 | func TestSecurityContext_Actor(t *testing.T) { 54 | expectedActor := Actor{ID: 1} 55 | ctx := NewActorContext(context.Background(), expectedActor) 56 | sctx := NewSecurityContext(ctx) 57 | 58 | act, ok := sctx.Actor() 59 | if ok { 60 | if !reflect.DeepEqual(act, expectedActor) { 61 | t.Error("provided and retrieved subject should be the same") 62 | } 63 | } else { 64 | t.Error("actor should be able retrieved") 65 | } 66 | } 67 | 68 | func TestSecurityContext_Actor_empty(t *testing.T) { 69 | sctx := NewSecurityContext(context.Background()) 70 | 71 | _, ok := sctx.Actor() 72 | if ok { 73 | t.Error("subject should not be there") 74 | } 75 | } 76 | 77 | func TestSecurityContext_Token(t *testing.T) { 78 | expectedToken := "00000000011" 79 | ctx := mnemosyne.NewAccessTokenContext(context.Background(), expectedToken) 80 | sctx := NewSecurityContext(ctx) 81 | 82 | token, err := sctx.Token() 83 | if err != nil { 84 | t.Fatalf("unexpected error: %s", err.Error()) 85 | } 86 | if token.AccessToken != string(expectedToken) { 87 | t.Error("provided and retrieved token should be the same") 88 | } 89 | } 90 | 91 | func TestSecurityContext_Token_empty(t *testing.T) { 92 | sctx := NewSecurityContext(context.Background()) 93 | 94 | _, err := sctx.Token() 95 | if err == nil { 96 | t.Error("expected error, got nil") 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /internal/password/password_test.go: -------------------------------------------------------------------------------- 1 | package password_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/piotrkowalczuk/charon/internal/password" 8 | 9 | "golang.org/x/crypto/bcrypt" 10 | ) 11 | 12 | func TestNewBCryptHasher_bellowMin(t *testing.T) { 13 | ph, err := password.NewBCryptHasher(bcrypt.MinCost - 1) 14 | 15 | testNewBCryptHasherFailure(t, ph, err) 16 | } 17 | 18 | func TestNewBCryptHasher_min(t *testing.T) { 19 | ph, err := password.NewBCryptHasher(bcrypt.MinCost) 20 | 21 | testNewBCryptHasherSuccess(t, ph, err) 22 | } 23 | 24 | func TestNewBCryptHasher_between(t *testing.T) { 25 | ph, err := password.NewBCryptHasher(25) 26 | 27 | testNewBCryptHasherSuccess(t, ph, err) 28 | } 29 | 30 | func TestNewBCryptHasher_max(t *testing.T) { 31 | ph, err := password.NewBCryptHasher(bcrypt.MaxCost) 32 | 33 | testNewBCryptHasherSuccess(t, ph, err) 34 | } 35 | 36 | func TestNewBCryptHasher_aboveMax(t *testing.T) { 37 | ph, err := password.NewBCryptHasher(bcrypt.MaxCost + 1) 38 | 39 | testNewBCryptHasherFailure(t, ph, err) 40 | } 41 | 42 | func testNewBCryptHasherSuccess(t *testing.T, ph password.Hasher, err error) { 43 | if err != nil { 44 | t.Fatalf("unexpected error %s", err.Error()) 45 | } 46 | if ph == nil { 47 | t.Error("password hasher should not be nil") 48 | } 49 | } 50 | 51 | func testNewBCryptHasherFailure(t *testing.T, ph password.Hasher, err error) { 52 | if err == nil { 53 | t.Error("error expected") 54 | } 55 | if ph != nil { 56 | t.Errorf("password hasher should be nil, but got %v", ph) 57 | } 58 | } 59 | 60 | func TestBCryptHasher_Hash(t *testing.T) { 61 | ph, err := password.NewBCryptHasher(10) 62 | if err != nil { 63 | t.Fatalf("unexpected error: %s", err.Error()) 64 | } 65 | 66 | given := []byte("123") 67 | expected := []byte("$2a$10$NF5jon4vHytVzwVz5wKAe.AycwRQ8mmeRXEoxTMu4kh4He7K1YCRe") 68 | 69 | got, err := ph.Hash(given) 70 | if err != nil { 71 | t.Fatalf("hash returned unexpected error: %s", err.Error()) 72 | } 73 | if len(expected) != len(got) { 74 | t.Errorf("length of hash do not match, expected %d but got %d", len(expected), len(got)) 75 | } 76 | if !bytes.HasPrefix(got, expected[:7]) { 77 | t.Errorf("hash should have prefix %s but does not: %s", expected[:7], got) 78 | } 79 | } 80 | 81 | func TestBCryptHasher_Compare(t *testing.T) { 82 | given := []byte("123") 83 | 84 | ph, err := password.NewBCryptHasher(10) 85 | if err != nil { 86 | t.Fatalf("unexpected error: %s", err.Error()) 87 | } 88 | 89 | got, err := ph.Hash(given) 90 | if err != nil { 91 | t.Fatalf("hash returned unexpected error: %s", err.Error()) 92 | } 93 | 94 | if !ph.Compare(got, given) { 95 | t.Error("password do not match") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pb/rpc/charond/v1mock/permission_manager_server.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package charondmock 4 | 5 | import charond "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 6 | import context "context" 7 | import mock "github.com/stretchr/testify/mock" 8 | 9 | // PermissionManagerServer is an autogenerated mock type for the PermissionManagerServer type 10 | type PermissionManagerServer struct { 11 | mock.Mock 12 | } 13 | 14 | // Get provides a mock function with given fields: _a0, _a1 15 | func (_m *PermissionManagerServer) Get(_a0 context.Context, _a1 *charond.GetPermissionRequest) (*charond.GetPermissionResponse, error) { 16 | ret := _m.Called(_a0, _a1) 17 | 18 | var r0 *charond.GetPermissionResponse 19 | if rf, ok := ret.Get(0).(func(context.Context, *charond.GetPermissionRequest) *charond.GetPermissionResponse); ok { 20 | r0 = rf(_a0, _a1) 21 | } else { 22 | if ret.Get(0) != nil { 23 | r0 = ret.Get(0).(*charond.GetPermissionResponse) 24 | } 25 | } 26 | 27 | var r1 error 28 | if rf, ok := ret.Get(1).(func(context.Context, *charond.GetPermissionRequest) error); ok { 29 | r1 = rf(_a0, _a1) 30 | } else { 31 | r1 = ret.Error(1) 32 | } 33 | 34 | return r0, r1 35 | } 36 | 37 | // List provides a mock function with given fields: _a0, _a1 38 | func (_m *PermissionManagerServer) List(_a0 context.Context, _a1 *charond.ListPermissionsRequest) (*charond.ListPermissionsResponse, error) { 39 | ret := _m.Called(_a0, _a1) 40 | 41 | var r0 *charond.ListPermissionsResponse 42 | if rf, ok := ret.Get(0).(func(context.Context, *charond.ListPermissionsRequest) *charond.ListPermissionsResponse); ok { 43 | r0 = rf(_a0, _a1) 44 | } else { 45 | if ret.Get(0) != nil { 46 | r0 = ret.Get(0).(*charond.ListPermissionsResponse) 47 | } 48 | } 49 | 50 | var r1 error 51 | if rf, ok := ret.Get(1).(func(context.Context, *charond.ListPermissionsRequest) error); ok { 52 | r1 = rf(_a0, _a1) 53 | } else { 54 | r1 = ret.Error(1) 55 | } 56 | 57 | return r0, r1 58 | } 59 | 60 | // Register provides a mock function with given fields: _a0, _a1 61 | func (_m *PermissionManagerServer) Register(_a0 context.Context, _a1 *charond.RegisterPermissionsRequest) (*charond.RegisterPermissionsResponse, error) { 62 | ret := _m.Called(_a0, _a1) 63 | 64 | var r0 *charond.RegisterPermissionsResponse 65 | if rf, ok := ret.Get(0).(func(context.Context, *charond.RegisterPermissionsRequest) *charond.RegisterPermissionsResponse); ok { 66 | r0 = rf(_a0, _a1) 67 | } else { 68 | if ret.Get(0) != nil { 69 | r0 = ret.Get(0).(*charond.RegisterPermissionsResponse) 70 | } 71 | } 72 | 73 | var r1 error 74 | if rf, ok := ret.Get(1).(func(context.Context, *charond.RegisterPermissionsRequest) error); ok { 75 | r1 = rf(_a0, _a1) 76 | } else { 77 | r1 = ret.Error(1) 78 | } 79 | 80 | return r0, r1 81 | } 82 | -------------------------------------------------------------------------------- /cmd/charond/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | var version string 10 | 11 | type configuration struct { 12 | host string 13 | port int 14 | test bool 15 | logger struct { 16 | environment string 17 | level string 18 | } 19 | mnemosyned struct { 20 | address string 21 | tls struct { 22 | enabled bool 23 | certFile string 24 | } 25 | } 26 | password struct { 27 | strategy string 28 | bcrypt struct { 29 | cost int 30 | } 31 | } 32 | monitoring struct { 33 | enabled bool 34 | } 35 | postgres struct { 36 | address string 37 | debug bool 38 | } 39 | tls struct { 40 | enabled bool 41 | certFile string 42 | keyFile string 43 | } 44 | } 45 | 46 | func (c *configuration) init() { 47 | if c == nil { 48 | *c = configuration{} 49 | } 50 | 51 | flag.StringVar(&c.host, "host", "127.0.0.1", "host") 52 | flag.IntVar(&c.port, "port", 8080, "port") 53 | flag.BoolVar(&c.test, "test", false, "determines in what mode application starts") 54 | // LOGGER 55 | flag.StringVar(&c.logger.environment, "log.environment", "production", "Logger environment config (production, stackdriver or development).") 56 | flag.StringVar(&c.logger.level, "log.level", "info", "Logger level (debug, info, warn, error, dpanic, panic, fatal)") 57 | // MNEMOSYNE 58 | flag.StringVar(&c.mnemosyned.address, "mnemosyned.address", "mnemosyned:8080", "mnemosyne daemon session store connection address") 59 | flag.BoolVar(&c.mnemosyned.tls.enabled, "mnemosyned.tls", false, "tls enable flag for mnemosyned client connection") 60 | flag.StringVar(&c.mnemosyned.tls.certFile, "mnemosyned.tls.crt", "", "path to tls cert file for mnemosyned client connection") 61 | // PASSWORD 62 | flag.StringVar(&c.password.strategy, "password.strategy", "bcrypt", "strategy how password will be stored") 63 | flag.IntVar(&c.password.bcrypt.cost, "password.bcryptcost", 10, "bcrypt cost, bigget than safer (and longer to create)") 64 | flag.BoolVar(&c.monitoring.enabled, "monitoring", false, "toggle application monitoring") 65 | // POSTGRES 66 | flag.StringVar(&c.postgres.address, "postgres.address", "postgres://postgres:postgres@postgres/postgres?sslmode=disable", "postgres connection string") 67 | flag.BoolVar(&c.postgres.debug, "postgres.debug", false, "if true database queries are logged") 68 | // TLS 69 | flag.BoolVar(&c.tls.enabled, "tls", false, "tls enable flag") 70 | flag.StringVar(&c.tls.certFile, "tls.crt", "", "path to tls cert file") 71 | flag.StringVar(&c.tls.keyFile, "tls.key", "", "path to tls key file") 72 | } 73 | 74 | func (c *configuration) parse() { 75 | if !flag.Parsed() { 76 | ver := flag.Bool("version", false, "print version and exit") 77 | flag.Parse() 78 | if *ver { 79 | fmt.Printf("%s\n", version) 80 | os.Exit(0) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /pb/rpc/charond/v1/group.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package charon.rpc.charond.v1; 4 | 5 | option go_package = "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1;charond"; 6 | option java_multiple_files = true; 7 | option java_package = "com.github.charon.rpc.charond.v1"; 8 | 9 | import "google/protobuf/timestamp.proto"; 10 | import "google/protobuf/wrappers.proto"; 11 | import "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1/common.proto"; 12 | import "ntypes/ntypes.proto"; 13 | 14 | service GroupManager { 15 | rpc Create(CreateGroupRequest) returns (CreateGroupResponse) {}; 16 | rpc Modify(ModifyGroupRequest) returns (ModifyGroupResponse) {}; 17 | rpc Get(GetGroupRequest) returns (GetGroupResponse) {}; 18 | rpc List(ListGroupsRequest) returns (ListGroupsResponse) {}; 19 | rpc Delete(DeleteGroupRequest) returns (google.protobuf.BoolValue) {}; 20 | 21 | rpc ListPermissions(ListGroupPermissionsRequest) returns (ListGroupPermissionsResponse) {}; 22 | rpc SetPermissions(SetGroupPermissionsRequest) returns (SetGroupPermissionsResponse) {}; 23 | } 24 | 25 | message Group { 26 | int64 id = 1; 27 | string name = 2; 28 | string description = 3; 29 | google.protobuf.Timestamp created_at = 4; 30 | ntypes.Int64 created_by = 5; 31 | google.protobuf.Timestamp updated_at = 6; 32 | ntypes.Int64 updated_by = 7; 33 | } 34 | 35 | message CreateGroupRequest { 36 | string name = 1; 37 | ntypes.String description = 2; 38 | } 39 | 40 | message CreateGroupResponse { 41 | Group group = 1; 42 | } 43 | 44 | message GetGroupRequest { 45 | int64 id = 1; 46 | } 47 | 48 | message GetGroupResponse { 49 | Group group = 1; 50 | } 51 | 52 | message ListGroupsRequest { 53 | reserved 1 to 99; 54 | ntypes.Int64 offset = 100; 55 | ntypes.Int64 limit = 101; 56 | repeated Order order_by = 102; 57 | } 58 | 59 | message ListGroupsResponse { 60 | repeated Group groups = 1; 61 | } 62 | 63 | message DeleteGroupRequest { 64 | int64 id = 1; 65 | } 66 | 67 | message ModifyGroupRequest { 68 | int64 id = 1; 69 | ntypes.String name = 2; 70 | ntypes.String description = 3; 71 | } 72 | 73 | message ModifyGroupResponse { 74 | Group group = 1; 75 | } 76 | 77 | message SetGroupPermissionsRequest { 78 | int64 group_id = 1; 79 | repeated string permissions = 2; 80 | // Force tells if permission should be created in case if it does not exists. 81 | bool force = 3; 82 | } 83 | 84 | message SetGroupPermissionsResponse { 85 | int64 created = 1; 86 | int64 removed = 2; 87 | int64 untouched = 3; 88 | } 89 | 90 | message ListGroupPermissionsRequest { 91 | int64 id = 1; 92 | } 93 | 94 | message ListGroupPermissionsResponse { 95 | repeated string permissions = 1; 96 | } -------------------------------------------------------------------------------- /pb/rpc/charond/v1mock/refresh_token_manager_server.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package charondmock 4 | 5 | import charond "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 6 | import context "context" 7 | import mock "github.com/stretchr/testify/mock" 8 | 9 | // RefreshTokenManagerServer is an autogenerated mock type for the RefreshTokenManagerServer type 10 | type RefreshTokenManagerServer struct { 11 | mock.Mock 12 | } 13 | 14 | // Create provides a mock function with given fields: _a0, _a1 15 | func (_m *RefreshTokenManagerServer) Create(_a0 context.Context, _a1 *charond.CreateRefreshTokenRequest) (*charond.CreateRefreshTokenResponse, error) { 16 | ret := _m.Called(_a0, _a1) 17 | 18 | var r0 *charond.CreateRefreshTokenResponse 19 | if rf, ok := ret.Get(0).(func(context.Context, *charond.CreateRefreshTokenRequest) *charond.CreateRefreshTokenResponse); ok { 20 | r0 = rf(_a0, _a1) 21 | } else { 22 | if ret.Get(0) != nil { 23 | r0 = ret.Get(0).(*charond.CreateRefreshTokenResponse) 24 | } 25 | } 26 | 27 | var r1 error 28 | if rf, ok := ret.Get(1).(func(context.Context, *charond.CreateRefreshTokenRequest) error); ok { 29 | r1 = rf(_a0, _a1) 30 | } else { 31 | r1 = ret.Error(1) 32 | } 33 | 34 | return r0, r1 35 | } 36 | 37 | // List provides a mock function with given fields: _a0, _a1 38 | func (_m *RefreshTokenManagerServer) List(_a0 context.Context, _a1 *charond.ListRefreshTokensRequest) (*charond.ListRefreshTokensResponse, error) { 39 | ret := _m.Called(_a0, _a1) 40 | 41 | var r0 *charond.ListRefreshTokensResponse 42 | if rf, ok := ret.Get(0).(func(context.Context, *charond.ListRefreshTokensRequest) *charond.ListRefreshTokensResponse); ok { 43 | r0 = rf(_a0, _a1) 44 | } else { 45 | if ret.Get(0) != nil { 46 | r0 = ret.Get(0).(*charond.ListRefreshTokensResponse) 47 | } 48 | } 49 | 50 | var r1 error 51 | if rf, ok := ret.Get(1).(func(context.Context, *charond.ListRefreshTokensRequest) error); ok { 52 | r1 = rf(_a0, _a1) 53 | } else { 54 | r1 = ret.Error(1) 55 | } 56 | 57 | return r0, r1 58 | } 59 | 60 | // Revoke provides a mock function with given fields: _a0, _a1 61 | func (_m *RefreshTokenManagerServer) Revoke(_a0 context.Context, _a1 *charond.RevokeRefreshTokenRequest) (*charond.RevokeRefreshTokenResponse, error) { 62 | ret := _m.Called(_a0, _a1) 63 | 64 | var r0 *charond.RevokeRefreshTokenResponse 65 | if rf, ok := ret.Get(0).(func(context.Context, *charond.RevokeRefreshTokenRequest) *charond.RevokeRefreshTokenResponse); ok { 66 | r0 = rf(_a0, _a1) 67 | } else { 68 | if ret.Get(0) != nil { 69 | r0 = ret.Get(0).(*charond.RevokeRefreshTokenResponse) 70 | } 71 | } 72 | 73 | var r1 error 74 | if rf, ok := ret.Get(1).(func(context.Context, *charond.RevokeRefreshTokenRequest) error); ok { 75 | r1 = rf(_a0, _a1) 76 | } else { 77 | r1 = ret.Error(1) 78 | } 79 | 80 | return r0, r1 81 | } 82 | -------------------------------------------------------------------------------- /internal/charond/handler_get_user.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/piotrkowalczuk/charon" 8 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 9 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 10 | "github.com/piotrkowalczuk/charon/internal/mapping" 11 | "github.com/piotrkowalczuk/charon/internal/model" 12 | "github.com/piotrkowalczuk/charon/internal/session" 13 | 14 | "google.golang.org/grpc/codes" 15 | ) 16 | 17 | type getUserHandler struct { 18 | *handler 19 | } 20 | 21 | func (guh *getUserHandler) Get(ctx context.Context, req *charonrpc.GetUserRequest) (*charonrpc.GetUserResponse, error) { 22 | if req.Id <= 0 { 23 | return nil, grpcerr.E(codes.InvalidArgument, "user id is missing") 24 | } 25 | 26 | act, err := guh.Actor(ctx) 27 | if err != nil { 28 | return nil, err 29 | } 30 | ent, err := guh.repository.user.FindOneByID(ctx, req.Id) 31 | if err != nil { 32 | if err == sql.ErrNoRows { 33 | return nil, grpcerr.E(codes.NotFound, "user does not exists") 34 | } 35 | return nil, grpcerr.E(codes.Internal, "user cannot be fetched", err) 36 | } 37 | if err = guh.firewall(req, act, ent); err != nil { 38 | return nil, err 39 | } 40 | 41 | return guh.response(ent) 42 | } 43 | 44 | func (guh *getUserHandler) firewall(req *charonrpc.GetUserRequest, act *session.Actor, ent *model.UserEntity) error { 45 | if act.User.IsSuperuser { 46 | return nil 47 | } 48 | if ent.IsSuperuser { 49 | return grpcerr.E(codes.PermissionDenied, "only superuser is permitted to retrieve other superuser") 50 | } 51 | if ent.IsStaff { 52 | if ent.CreatedBy.Int64Or(0) == act.User.ID { 53 | if !act.Permissions.Contains(charon.UserCanRetrieveStaffAsOwner) { 54 | return grpcerr.E(codes.PermissionDenied, "staff user cannot be retrieved as an owner, missing permission") 55 | } 56 | return nil 57 | } 58 | if !act.Permissions.Contains(charon.UserCanRetrieveStaffAsStranger) { 59 | return grpcerr.E(codes.PermissionDenied, "staff user cannot be retrieved as a stranger, missing permission") 60 | } 61 | return nil 62 | } 63 | if ent.CreatedBy.Int64Or(0) == act.User.ID { 64 | if !act.Permissions.Contains(charon.UserCanRetrieveAsOwner) { 65 | return grpcerr.E(codes.PermissionDenied, "user cannot be retrieved as an owner, missing permission") 66 | } 67 | return nil 68 | } 69 | if !act.Permissions.Contains(charon.UserCanRetrieveAsStranger) { 70 | return grpcerr.E(codes.PermissionDenied, "user cannot be retrieved as a stranger, missing permission") 71 | } 72 | return nil 73 | } 74 | 75 | func (guh *getUserHandler) response(ent *model.UserEntity) (*charonrpc.GetUserResponse, error) { 76 | msg, err := mapping.ReverseUser(ent) 77 | if err != nil { 78 | return nil, grpcerr.E(codes.Internal, "user entity mapping failure", err) 79 | } 80 | return &charonrpc.GetUserResponse{ 81 | User: msg, 82 | }, nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/charond/handler_login.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/piotrkowalczuk/charon/internal/service" 8 | "go.uber.org/zap" 9 | 10 | "github.com/golang/protobuf/ptypes/wrappers" 11 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 12 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 13 | "github.com/piotrkowalczuk/charon/internal/session" 14 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 15 | 16 | "google.golang.org/grpc/codes" 17 | ) 18 | 19 | type loginHandler struct { 20 | *handler 21 | userFinderFactory *service.UserFinderFactory 22 | } 23 | 24 | // Login implements charonrpc AuthClient interface. 25 | func (lh *loginHandler) Login(ctx context.Context, r *charonrpc.LoginRequest) (*wrappers.StringValue, error) { 26 | if r.GetUsername() != "" || r.GetPassword() != "" { 27 | r.Strategy = &charonrpc.LoginRequest_UsernameAndPassword{ 28 | UsernameAndPassword: &charonrpc.UsernameAndPasswordStrategy{ 29 | Username: r.GetUsername(), 30 | Password: r.GetPassword(), 31 | }, 32 | } 33 | } 34 | 35 | var ( 36 | userFinder service.UserFinder 37 | refreshToken string 38 | ) 39 | switch str := r.GetStrategy().(type) { 40 | case *charonrpc.LoginRequest_UsernameAndPassword: 41 | userFinder = lh.userFinderFactory.ByUsernameAndPassword( 42 | str.UsernameAndPassword.GetUsername(), 43 | str.UsernameAndPassword.GetPassword(), 44 | ) 45 | case *charonrpc.LoginRequest_RefreshToken: 46 | refreshToken = str.RefreshToken.GetRefreshToken() 47 | userFinder = lh.userFinderFactory.ByRefreshToken(refreshToken) 48 | } 49 | 50 | usr, err := userFinder.FindUser(ctx) 51 | if err != nil { 52 | fmt.Println("error", err) 53 | return nil, err 54 | } 55 | 56 | if !usr.IsConfirmed { 57 | return nil, grpcerr.E(codes.Unauthenticated, "user is not confirmed") 58 | } 59 | 60 | if !usr.IsActive { 61 | return nil, grpcerr.E(codes.Unauthenticated, "user is not active") 62 | } 63 | 64 | res, err := lh.session.Start(ctx, &mnemosynerpc.StartRequest{ 65 | Session: &mnemosynerpc.Session{ 66 | SubjectId: session.ActorIDFromInt64(usr.ID).String(), 67 | SubjectClient: r.Client, 68 | RefreshToken: refreshToken, 69 | Bag: map[string]string{ 70 | "username": usr.Username, 71 | "first_name": usr.FirstName, 72 | "last_name": usr.LastName, 73 | }, 74 | }, 75 | }) 76 | if err != nil { 77 | return nil, grpcerr.E("session start on login failure", err) 78 | } 79 | 80 | lh.logger.Debug("user session has been started", zap.Int64("user_id", usr.ID)) 81 | 82 | _, err = lh.repository.user.UpdateLastLoginAt(ctx, usr.ID) 83 | if err != nil { 84 | return nil, grpcerr.E(codes.Internal, "last login update failure: %s", err) 85 | } 86 | 87 | lh.logger.Debug("user last login at field has been updated", zap.Int64("user_id", usr.ID)) 88 | 89 | return &wrappers.StringValue{Value: res.Session.AccessToken}, nil 90 | } 91 | -------------------------------------------------------------------------------- /cmd/charonctl/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | 8 | "time" 9 | 10 | "github.com/piotrkowalczuk/charon" 11 | ) 12 | 13 | type configuration struct { 14 | cl *flag.FlagSet 15 | address string 16 | auth struct { 17 | username string 18 | password string 19 | enabled bool 20 | } 21 | register struct { 22 | ifNotExists bool 23 | username string 24 | password string 25 | firstName string 26 | lastName string 27 | superuser bool 28 | confirmed bool 29 | staff bool 30 | active bool 31 | permissions charon.Permissions 32 | } 33 | refreshToken struct { 34 | expireAfter time.Duration 35 | notes string 36 | } 37 | fixtures struct { 38 | path string 39 | } 40 | } 41 | 42 | func (c *configuration) init() { 43 | *c = configuration{ 44 | cl: flag.NewFlagSet(os.Args[0], flag.ExitOnError), 45 | } 46 | 47 | c.cl.Usage = func() { 48 | fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) 49 | c.cl.PrintDefaults() 50 | } 51 | c.cl.StringVar(&c.address, "address", "charond:8080", "charon address") 52 | c.cl.BoolVar(&c.auth.enabled, "auth", true, "authorization check flag") 53 | c.cl.StringVar(&c.auth.username, "auth.username", "", "username") 54 | c.cl.StringVar(&c.auth.password, "auth.password", "", "password") 55 | // register 56 | c.cl.BoolVar(&c.register.ifNotExists, "register.ifnotexists", false, "application does not fail if user already exists") 57 | c.cl.StringVar(&c.register.username, "register.username", "", "username") 58 | c.cl.StringVar(&c.register.password, "register.password", "", "password") 59 | c.cl.StringVar(&c.register.firstName, "register.firstname", "", "first name") 60 | c.cl.StringVar(&c.register.lastName, "register.lastname", "", "last name") 61 | c.cl.Var(&c.register.permissions, "register.permission", "list of permissions that user should") 62 | c.cl.BoolVar(&c.register.superuser, "register.superuser", false, "is user the superuser") 63 | c.cl.BoolVar(&c.register.confirmed, "register.confirmed", false, "is user account confirmed") 64 | c.cl.BoolVar(&c.register.staff, "register.staff", false, "is user part of the staff") 65 | c.cl.BoolVar(&c.register.active, "register.active", false, "is user account active") 66 | // refresh token 67 | c.cl.DurationVar(&c.refreshToken.expireAfter, "refreshtoken.expireafter", 0, "duration after which token expires") 68 | c.cl.StringVar(&c.refreshToken.notes, "refreshtoken.notes", "", "extra notes") 69 | // fixtures 70 | c.cl.StringVar(&c.fixtures.path, "fixtures.path", "", "path to the fixtures path") 71 | 72 | } 73 | 74 | func (c *configuration) parse() { 75 | if c == nil || c.cl == nil { 76 | c.init() 77 | } 78 | if !c.cl.Parsed() { 79 | if len(os.Args) > 1 { 80 | c.cl.Parse(os.Args[2:]) 81 | } 82 | } 83 | } 84 | 85 | func (c *configuration) cmd() string { 86 | if len(os.Args) > 1 { 87 | return os.Args[1] 88 | } 89 | return "help" 90 | } 91 | -------------------------------------------------------------------------------- /internal/model/modelmock/rows.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package modelmock 4 | 5 | import mock "github.com/stretchr/testify/mock" 6 | 7 | import sql "database/sql" 8 | 9 | // Rows is an autogenerated mock type for the Rows type 10 | type Rows struct { 11 | mock.Mock 12 | } 13 | 14 | // Close provides a mock function with given fields: 15 | func (_m *Rows) Close() error { 16 | ret := _m.Called() 17 | 18 | var r0 error 19 | if rf, ok := ret.Get(0).(func() error); ok { 20 | r0 = rf() 21 | } else { 22 | r0 = ret.Error(0) 23 | } 24 | 25 | return r0 26 | } 27 | 28 | // ColumnTypes provides a mock function with given fields: 29 | func (_m *Rows) ColumnTypes() ([]*sql.ColumnType, error) { 30 | ret := _m.Called() 31 | 32 | var r0 []*sql.ColumnType 33 | if rf, ok := ret.Get(0).(func() []*sql.ColumnType); ok { 34 | r0 = rf() 35 | } else { 36 | if ret.Get(0) != nil { 37 | r0 = ret.Get(0).([]*sql.ColumnType) 38 | } 39 | } 40 | 41 | var r1 error 42 | if rf, ok := ret.Get(1).(func() error); ok { 43 | r1 = rf() 44 | } else { 45 | r1 = ret.Error(1) 46 | } 47 | 48 | return r0, r1 49 | } 50 | 51 | // Columns provides a mock function with given fields: 52 | func (_m *Rows) Columns() ([]string, error) { 53 | ret := _m.Called() 54 | 55 | var r0 []string 56 | if rf, ok := ret.Get(0).(func() []string); ok { 57 | r0 = rf() 58 | } else { 59 | if ret.Get(0) != nil { 60 | r0 = ret.Get(0).([]string) 61 | } 62 | } 63 | 64 | var r1 error 65 | if rf, ok := ret.Get(1).(func() error); ok { 66 | r1 = rf() 67 | } else { 68 | r1 = ret.Error(1) 69 | } 70 | 71 | return r0, r1 72 | } 73 | 74 | // Err provides a mock function with given fields: 75 | func (_m *Rows) Err() error { 76 | ret := _m.Called() 77 | 78 | var r0 error 79 | if rf, ok := ret.Get(0).(func() error); ok { 80 | r0 = rf() 81 | } else { 82 | r0 = ret.Error(0) 83 | } 84 | 85 | return r0 86 | } 87 | 88 | // Next provides a mock function with given fields: 89 | func (_m *Rows) Next() bool { 90 | ret := _m.Called() 91 | 92 | var r0 bool 93 | if rf, ok := ret.Get(0).(func() bool); ok { 94 | r0 = rf() 95 | } else { 96 | r0 = ret.Get(0).(bool) 97 | } 98 | 99 | return r0 100 | } 101 | 102 | // NextResultSet provides a mock function with given fields: 103 | func (_m *Rows) NextResultSet() bool { 104 | ret := _m.Called() 105 | 106 | var r0 bool 107 | if rf, ok := ret.Get(0).(func() bool); ok { 108 | r0 = rf() 109 | } else { 110 | r0 = ret.Get(0).(bool) 111 | } 112 | 113 | return r0 114 | } 115 | 116 | // Scan provides a mock function with given fields: dst 117 | func (_m *Rows) Scan(dst ...interface{}) error { 118 | var _ca []interface{} 119 | _ca = append(_ca, dst...) 120 | ret := _m.Called(_ca...) 121 | 122 | var r0 error 123 | if rf, ok := ret.Get(0).(func(...interface{}) error); ok { 124 | r0 = rf(dst...) 125 | } else { 126 | r0 = ret.Error(0) 127 | } 128 | 129 | return r0 130 | } 131 | -------------------------------------------------------------------------------- /scripts/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | : ${CHAROND_PORT:=8080} 5 | : ${CHAROND_HOST:=0.0.0.0} 6 | : ${MNEMOSYNED_LOG_ENVIRONMENT:=production} 7 | : ${MNEMOSYNED_LOG_LEVEL:=info} 8 | : ${CHAROND_STORAGE:=postgres} 9 | : ${CHAROND_MONITORING:=false} 10 | : ${CHAROND_PASSWORD_BCRYPT_COST:=10} 11 | : ${CHAROND_MNEMOSYNED_ADDRESS:=mnemosyned:8080} 12 | : ${CHAROND_MNEMOSYNED_TLS_ENABLED:=false} 13 | : ${CHAROND_POSTGRES_ADDRESS:=postgres://postgres:postgres@postgres/postgres?sslmode=disable} 14 | : ${CHAROND_POSTGRES_DEBUG:=false} 15 | : ${CHAROND_TLS_ENABLED:=false} 16 | 17 | if [ "$1" = 'charond' ]; then 18 | exec charond \ 19 | -host=${CHAROND_HOST} \ 20 | -port=${CHAROND_PORT} \ 21 | -log.environment=${CHAROND_LOG_ENVIRONMENT} \ 22 | -log.level=${CHAROND_LOG_LEVEL} \ 23 | -mnemosyned.address=${CHAROND_MNEMOSYNED_ADDRESS} \ 24 | -mnemosyned.tls=${CHAROND_MNEMOSYNED_TLS_ENABLED} \ 25 | -mnemosyned.tls.crt=${CHAROND_MNEMOSYNED_TLS_CRT} \ 26 | -password.strategy=${CHAROND_PASSWORD_STRATEGY} \ 27 | -password.bcryptcost=${CHAROND_PASSWORD_BCRYPT_COST} \ 28 | -monitoring=${CHAROND_MONITORING} \ 29 | -postgres.address=${CHAROND_POSTGRES_ADDRESS} \ 30 | -postgres.debug=${CHAROND_POSTGRES_DEBUG} \ 31 | -tls=${CHAROND_TLS_ENABLED} \ 32 | -tls.crt=${CHAROND_TLS_CRT} \ 33 | -tls.key=${CHAROND_TLS_KEY} 34 | fi 35 | 36 | : ${CHARONCTL_CHAROND_HOST:=charond} 37 | : ${CHARONCTL_AUTH_ENABLED:=true} 38 | : ${CHARONCTL_REGISTER_SUPERUSER:=false} 39 | : ${CHARONCTL_REGISTER_CONFIRMED:=false} 40 | : ${CHARONCTL_REGISTER_STAFF:=false} 41 | : ${CHARONCTL_REGISTER_ACTIVE:=false} 42 | : ${CHARONCTL_REGISTER_IF_NOT_EXISTS:=false} 43 | : ${CHARONCTL_REGISTER_PERMISSIONS:=""} 44 | : ${CHARONCTL_FIXTURES_PATH:="/data/fixtures.json"} 45 | 46 | if [ "$1" = 'charonctl' ]; then 47 | if [ "$2" = 'register' ]; then 48 | eval charonctl register \ 49 | -address='${CHARONCTL_CHAROND_HOST}:${CHAROND_PORT}' \ 50 | -auth=${CHARONCTL_AUTH_ENABLED} \ 51 | -auth.username=\"${CHARONCTL_AUTH_USERNAME}\" \ 52 | -auth.password=\"${CHARONCTL_AUTH_PASSWORD}\" \ 53 | -register.ifnotexists=${CHARONCTL_REGISTER_IF_NOT_EXISTS} \ 54 | -register.username=\"${CHARONCTL_REGISTER_USERNAME}\" \ 55 | -register.password=\"${CHARONCTL_REGISTER_PASSWORD}\" \ 56 | -register.firstname=\"${CHARONCTL_REGISTER_FIRSTNAME}\" \ 57 | -register.lastname=\"${CHARONCTL_REGISTER_LASTNAME}\" \ 58 | -register.superuser=${CHARONCTL_REGISTER_SUPERUSER} \ 59 | -register.confirmed=${CHARONCTL_REGISTER_CONFIRMED} \ 60 | -register.staff=${CHARONCTL_REGISTER_STAFF} \ 61 | -register.active=${CHARONCTL_REGISTER_ACTIVE} \ 62 | -register.permission=\"${CHARONCTL_REGISTER_PERMISSIONS}\" 63 | exit $? 64 | fi 65 | if [ "$2" = 'load' ]; then 66 | eval charonctl load \ 67 | -address='${CHARONCTL_CHAROND_HOST}:${CHAROND_PORT}' \ 68 | -auth=${CHARONCTL_AUTH_ENABLED} \ 69 | -auth.username=\"${CHARONCTL_AUTH_USERNAME}\" \ 70 | -auth.password=\"${CHARONCTL_AUTH_PASSWORD}\" \ 71 | -fixtures.path=\"${CHARONCTL_FIXTURES_PATH}\" 72 | exit $? 73 | fi 74 | fi 75 | 76 | exec "$@" 77 | -------------------------------------------------------------------------------- /internal/session/actor_provider.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "strings" 7 | 8 | "github.com/golang/protobuf/ptypes/empty" 9 | "github.com/piotrkowalczuk/charon" 10 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 11 | "github.com/piotrkowalczuk/charon/internal/model" 12 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 13 | "google.golang.org/grpc/codes" 14 | "google.golang.org/grpc/metadata" 15 | "google.golang.org/grpc/peer" 16 | "google.golang.org/grpc/status" 17 | ) 18 | 19 | type ActorProvider interface { 20 | Actor(context.Context) (*Actor, error) 21 | } 22 | 23 | type MnemosyneActorProvider struct { 24 | Client mnemosynerpc.SessionManagerClient 25 | UserProvider model.UserProvider 26 | PermissionProvider model.PermissionProvider 27 | } 28 | 29 | func (p *MnemosyneActorProvider) Actor(ctx context.Context) (*Actor, error) { 30 | var ( 31 | act *Actor 32 | userID int64 33 | entities []*model.PermissionEntity 34 | res *mnemosynerpc.ContextResponse 35 | ) 36 | 37 | res, err := p.Client.Context(ctx, &empty.Empty{}) 38 | if err != nil { 39 | if isLocal(ctx) { 40 | return &Actor{ 41 | User: &model.UserEntity{}, 42 | IsLocal: true, 43 | }, nil 44 | } 45 | return nil, handleMnemosyneError(err) 46 | } 47 | 48 | userID, err = ActorID(res.Session.SubjectId).UserID() 49 | if err != nil { 50 | return nil, grpcerr.E(codes.InvalidArgument, err) 51 | } 52 | 53 | act = &Actor{} 54 | act.User, err = p.UserProvider.FindOneByID(ctx, userID) 55 | if err != nil { 56 | if err == sql.ErrNoRows { 57 | return nil, grpcerr.E(codes.PermissionDenied, "actor does not exists") 58 | } 59 | return nil, grpcerr.E(codes.Internal, "actor fetch failure", err) 60 | } 61 | entities, err = p.PermissionProvider.FindByUserID(ctx, userID) 62 | if err != nil { 63 | if err == sql.ErrNoRows { 64 | return act, nil 65 | } 66 | return nil, grpcerr.E(codes.Internal, "permissions fetch failure", err) 67 | } 68 | 69 | act.Permissions = make(charon.Permissions, 0, len(entities)) 70 | for _, e := range entities { 71 | act.Permissions = append(act.Permissions, e.Permission()) 72 | } 73 | 74 | return act, nil 75 | } 76 | 77 | func isLocal(ctx context.Context) bool { 78 | if p, ok := peer.FromContext(ctx); ok { 79 | if strings.HasPrefix(p.Addr.String(), "127.0.0.1") { 80 | if md, ok := metadata.FromIncomingContext(ctx); ok { 81 | if len(md["user-agent"]) == 1 && strings.HasPrefix(md["user-agent"][0], "charonctl") { 82 | return true 83 | } 84 | } 85 | } 86 | } 87 | return false 88 | } 89 | 90 | func handleMnemosyneError(err error) error { 91 | if sts, ok := status.FromError(err); ok { 92 | switch sts.Code() { 93 | case codes.NotFound: 94 | return grpcerr.E(codes.Unauthenticated, "session not found") 95 | case codes.InvalidArgument: 96 | return grpcerr.E(codes.Unauthenticated, sts.Message()) 97 | } 98 | } 99 | 100 | return grpcerr.E(codes.Internal, "session fetch failure", err) 101 | } 102 | -------------------------------------------------------------------------------- /internal/charond/service.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/piotrkowalczuk/charon" 11 | "github.com/piotrkowalczuk/charon/internal/model" 12 | "github.com/piotrkowalczuk/charon/internal/password" 13 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 14 | "go.uber.org/zap" 15 | "google.golang.org/grpc" 16 | ) 17 | 18 | func initPostgres(address string, test bool, logger *zap.Logger) (*sql.DB, error) { 19 | db, err := sql.Open("postgres", address) 20 | if err != nil { 21 | return nil, fmt.Errorf("connection failure: %s", err.Error()) 22 | } 23 | 24 | if err = db.Ping(); err != nil { 25 | cancel := time.NewTimer(10 * time.Second) 26 | attempts := 1 27 | PingLoop: 28 | for { 29 | select { 30 | case <-time.After(1 * time.Second): 31 | if err := db.Ping(); err != nil { 32 | attempts++ 33 | continue PingLoop 34 | } 35 | break PingLoop 36 | case <-cancel.C: 37 | return nil, fmt.Errorf("postgres connection failed after %d attempts", attempts) 38 | } 39 | } 40 | } 41 | 42 | if test { 43 | if err = teardownDatabase(db); err != nil { 44 | return nil, err 45 | } 46 | logger.Info("database has been cleared upfront") 47 | } 48 | if err = setupDatabase(db); err != nil { 49 | return nil, err 50 | } 51 | 52 | u, err := url.Parse(address) 53 | if err != nil { 54 | return nil, err 55 | } 56 | username := "" 57 | if u.User != nil { 58 | username = u.User.Username() 59 | } 60 | 61 | logger.Info("postgres connection has been established", zap.String("host", u.Host), zap.String("username", username)) 62 | 63 | return db, nil 64 | } 65 | 66 | func initMnemosyne(address string, logger *zap.Logger, opts []grpc.DialOption) (mnemosynerpc.SessionManagerClient, *grpc.ClientConn) { 67 | if address == "" { 68 | logger.Error("missing mnemosyne address") 69 | } 70 | conn, err := grpc.Dial(address, opts...) 71 | if err != nil { 72 | logger.Error("mnemosyne dial falilure", zap.Error(err), zap.String("address", address)) 73 | } 74 | 75 | logger.Info("rpc connection to mnemosyne has been established", zap.String("address", address)) 76 | 77 | return mnemosynerpc.NewSessionManagerClient(conn), conn 78 | } 79 | 80 | func initHasher(cost int, logger *zap.Logger) password.Hasher { 81 | bh, err := password.NewBCryptHasher(cost) 82 | if err != nil { 83 | logger.Fatal("hasher initialization failure", zap.Error(err)) 84 | } 85 | 86 | return bh 87 | } 88 | 89 | func initPermissionRegistry(r model.PermissionProvider, permissions charon.Permissions, logger *zap.Logger) (pr model.PermissionRegistry) { 90 | pr = model.NewPermissionRegistry(r) 91 | created, untouched, removed, err := pr.Register(context.TODO(), permissions) 92 | if err != nil { 93 | logger.Fatal("permission registry initialization failure", zap.Error(err)) 94 | } 95 | 96 | logger.Info("charon permissions has been registered", zap.Int64("created", created), zap.Int64("untouched", untouched), zap.Int64("removed", removed)) 97 | 98 | return 99 | } 100 | -------------------------------------------------------------------------------- /internal/charond/handler_create_refresh_token.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/golang/protobuf/ptypes" 7 | "github.com/lib/pq" 8 | "github.com/piotrkowalczuk/charon" 9 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 10 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 11 | "github.com/piotrkowalczuk/charon/internal/mapping" 12 | "github.com/piotrkowalczuk/charon/internal/model" 13 | "github.com/piotrkowalczuk/charon/internal/refreshtoken" 14 | "github.com/piotrkowalczuk/charon/internal/session" 15 | "github.com/piotrkowalczuk/ntypes" 16 | "golang.org/x/net/context" 17 | "google.golang.org/grpc/codes" 18 | ) 19 | 20 | type createRefreshTokenHandler struct { 21 | *handler 22 | } 23 | 24 | func (crth *createRefreshTokenHandler) Create(ctx context.Context, req *charonrpc.CreateRefreshTokenRequest) (*charonrpc.CreateRefreshTokenResponse, error) { 25 | var ( 26 | expireAt time.Time 27 | err error 28 | ) 29 | if req.ExpireAt != nil { 30 | if expireAt, err = ptypes.Timestamp(req.ExpireAt); err != nil { 31 | return nil, grpcerr.E(codes.InvalidArgument, "invalid format of expire at", err) 32 | } 33 | } 34 | 35 | act, err := crth.Actor(ctx) 36 | if err != nil { 37 | return nil, err 38 | } 39 | if err = crth.firewall(req, act); err != nil { 40 | return nil, err 41 | } 42 | 43 | tkn, err := refreshtoken.Random() 44 | if err != nil { 45 | return nil, grpcerr.E(codes.Internal, "refresh token generation failure", err) 46 | } 47 | 48 | ent, err := crth.repository.refreshToken.Create(ctx, &model.RefreshTokenEntity{ 49 | UserID: act.User.ID, 50 | Token: tkn, 51 | ExpireAt: pq.NullTime{ 52 | Time: expireAt.UTC(), 53 | Valid: !expireAt.IsZero(), 54 | }, 55 | CreatedBy: ntypes.Int64{Int64: act.User.ID, Valid: true}, 56 | Notes: allocNilString(req.Notes), 57 | }) 58 | if err != nil { 59 | switch model.ErrorConstraint(err) { 60 | case model.TableRefreshTokenConstraintCreatedByForeignKey: 61 | return nil, grpcerr.E(codes.NotFound, "such user does not exist") 62 | case model.TableRefreshTokenConstraintTokenUnique: 63 | return nil, grpcerr.E(codes.AlreadyExists, "such refresh token already exists") 64 | case model.TableRefreshTokenConstraintUserIDForeignKey: 65 | return nil, grpcerr.E(codes.NotFound, "such user does not exist") 66 | default: 67 | return nil, grpcerr.E(codes.Internal, "refresh token persistence failure", err) 68 | } 69 | } 70 | 71 | return crth.response(ent) 72 | } 73 | 74 | func (crth *createRefreshTokenHandler) firewall(req *charonrpc.CreateRefreshTokenRequest, act *session.Actor) error { 75 | if act.User.IsSuperuser { 76 | return nil 77 | } 78 | if act.Permissions.Contains(charon.RefreshTokenCanCreate) { 79 | return nil 80 | } 81 | 82 | return grpcerr.E(codes.PermissionDenied, "refresh token cannot be created, missing permission") 83 | } 84 | 85 | func (crth *createRefreshTokenHandler) response(ent *model.RefreshTokenEntity) (*charonrpc.CreateRefreshTokenResponse, error) { 86 | msg, err := mapping.ReverseRefreshToken(ent) 87 | if err != nil { 88 | return nil, grpcerr.E(codes.Internal, "refresh token entity mapping failure", err) 89 | } 90 | return &charonrpc.CreateRefreshTokenResponse{ 91 | RefreshToken: msg, 92 | }, nil 93 | } 94 | -------------------------------------------------------------------------------- /internal/charond/handler_delete_user.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | 7 | "github.com/golang/protobuf/ptypes/wrappers" 8 | "github.com/piotrkowalczuk/charon" 9 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 10 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 11 | "github.com/piotrkowalczuk/charon/internal/model" 12 | "github.com/piotrkowalczuk/charon/internal/session" 13 | 14 | "google.golang.org/grpc/codes" 15 | ) 16 | 17 | type deleteUserHandler struct { 18 | *handler 19 | } 20 | 21 | func (duh *deleteUserHandler) Delete(ctx context.Context, req *charonrpc.DeleteUserRequest) (*wrappers.BoolValue, error) { 22 | if req.Id <= 0 { 23 | return nil, grpcerr.E(codes.InvalidArgument, "user cannot be deleted, invalid id") 24 | } 25 | 26 | act, err := duh.Actor(ctx) 27 | if err != nil { 28 | return nil, err 29 | } 30 | ent, err := duh.repository.user.FindOneByID(ctx, req.Id) 31 | if err != nil { 32 | if err == sql.ErrNoRows { 33 | return nil, grpcerr.E(codes.NotFound, "user does not exists") 34 | } 35 | return nil, grpcerr.E(codes.Internal, "user retrieval failure", err) 36 | } 37 | if err = duh.firewall(req, act, ent); err != nil { 38 | return nil, err 39 | } 40 | 41 | aff, err := duh.repository.user.DeleteOneByID(ctx, req.Id) 42 | if err != nil { 43 | switch model.ErrorConstraint(err) { 44 | case model.TableUserGroupsConstraintUserIDForeignKey: 45 | return nil, grpcerr.E(codes.FailedPrecondition, "user cannot be removed, groups are assigned to it") 46 | case model.TableUserPermissionsConstraintUserIDForeignKey: 47 | return nil, grpcerr.E(codes.FailedPrecondition, "user cannot be removed, permissions are assigned to it") 48 | default: 49 | return nil, grpcerr.E(codes.Internal, "user cannot be removed", err) 50 | } 51 | } 52 | 53 | return &wrappers.BoolValue{Value: aff > 0}, nil 54 | } 55 | 56 | func (duh *deleteUserHandler) firewall(req *charonrpc.DeleteUserRequest, act *session.Actor, ent *model.UserEntity) error { 57 | if act.User.ID == ent.ID { 58 | return grpcerr.E(codes.PermissionDenied, "user is not permitted to remove himself") 59 | } 60 | if act.User.IsSuperuser { 61 | return nil 62 | } 63 | if ent.IsSuperuser { 64 | return grpcerr.E(codes.PermissionDenied, "only superuser can remove other superuser") 65 | } 66 | if ent.IsStaff { 67 | switch { 68 | case act.User.ID == ent.CreatedBy.Int64Or(0): 69 | if !act.Permissions.Contains(charon.UserCanDeleteStaffAsOwner) { 70 | return grpcerr.E(codes.PermissionDenied, "staff user cannot be removed by owner, missing permission") 71 | } 72 | return nil 73 | case !act.Permissions.Contains(charon.UserCanDeleteStaffAsStranger): 74 | return grpcerr.E(codes.PermissionDenied, "staff user cannot be removed by stranger, missing permission") 75 | } 76 | return nil 77 | } 78 | 79 | if act.User.ID == ent.CreatedBy.Int64Or(0) { 80 | if !act.Permissions.Contains(charon.UserCanDeleteAsOwner) { 81 | return grpcerr.E(codes.PermissionDenied, "user cannot be removed by owner, missing permission") 82 | } 83 | return nil 84 | } 85 | if !act.Permissions.Contains(charon.UserCanDeleteAsStranger) { 86 | return grpcerr.E(codes.PermissionDenied, "user cannot be removed by stranger, missing permission") 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /internal/service/user_finder.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "database/sql" 7 | 8 | "github.com/piotrkowalczuk/charon/internal/password" 9 | 10 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 11 | "github.com/piotrkowalczuk/charon/internal/model" 12 | "google.golang.org/grpc/codes" 13 | ) 14 | 15 | type UserFinder interface { 16 | FindUser(context.Context) (*model.UserEntity, error) 17 | } 18 | 19 | type UserFinderFactory struct { 20 | UserRepository model.UserProvider 21 | RefreshTokenRepository model.RefreshTokenProvider 22 | Hasher password.Hasher 23 | } 24 | 25 | func (f *UserFinderFactory) ByUsernameAndPassword(username, password string) UserFinder { 26 | return &byUsernameAndPasswordUserFinder{ 27 | username: username, 28 | password: password, 29 | userRepository: f.UserRepository, 30 | hasher: f.Hasher, 31 | } 32 | } 33 | 34 | func (f *UserFinderFactory) ByRefreshToken(refreshToken string) UserFinder { 35 | return &byRefreshTokenUserFinder{ 36 | refreshToken: refreshToken, 37 | userRepository: f.UserRepository, 38 | refreshTokenRepository: f.RefreshTokenRepository, 39 | } 40 | } 41 | 42 | type byUsernameAndPasswordUserFinder struct { 43 | username, password string 44 | userRepository model.UserProvider 45 | hasher password.Hasher 46 | } 47 | 48 | var _ UserFinder = &byUsernameAndPasswordUserFinder{} 49 | 50 | func (f *byUsernameAndPasswordUserFinder) FindUser(ctx context.Context) (*model.UserEntity, error) { 51 | if f.username == "" { 52 | return nil, grpcerr.E(codes.InvalidArgument, "empty username") 53 | } 54 | if len(f.password) == 0 { 55 | return nil, grpcerr.E(codes.InvalidArgument, "empty password") 56 | } 57 | 58 | usr, err := f.userRepository.FindOneByUsername(ctx, f.username) 59 | if err != nil { 60 | if err == sql.ErrNoRows { 61 | return nil, grpcerr.E(codes.Unauthenticated, "user with such username or password does not exists") 62 | } 63 | return nil, err 64 | } 65 | 66 | if bytes.Equal(usr.Password, model.ExternalPassword) { 67 | return nil, grpcerr.E(codes.FailedPrecondition, "authentication failure, external password manager not implemented") 68 | } 69 | if matches := f.hasher.Compare(usr.Password, []byte(f.password)); !matches { 70 | return nil, grpcerr.E(codes.Unauthenticated, "user with such username or password does not exists") 71 | } 72 | 73 | return usr, nil 74 | } 75 | 76 | type byRefreshTokenUserFinder struct { 77 | refreshToken string 78 | userRepository model.UserProvider 79 | refreshTokenRepository model.RefreshTokenProvider 80 | } 81 | 82 | var _ UserFinder = &byRefreshTokenUserFinder{} 83 | 84 | func (f *byRefreshTokenUserFinder) FindUser(ctx context.Context) (*model.UserEntity, error) { 85 | if f.refreshToken == "" { 86 | return nil, grpcerr.E(codes.InvalidArgument, "empty refresh token") 87 | } 88 | 89 | refreshToken, err := f.refreshTokenRepository.FindOneByToken(ctx, f.refreshToken) 90 | if err != nil { 91 | return nil, err 92 | } 93 | user, err := f.userRepository.FindOneByID(ctx, refreshToken.UserID) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | return user, nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/charond/handler_revoke_refresh_token.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/piotrkowalczuk/charon" 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 9 | "github.com/piotrkowalczuk/charon/internal/mapping" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/session" 12 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 13 | "github.com/piotrkowalczuk/ntypes" 14 | "go.uber.org/zap" 15 | "golang.org/x/net/context" 16 | "google.golang.org/grpc/codes" 17 | "google.golang.org/grpc/status" 18 | ) 19 | 20 | type revokeRefreshTokenHandler struct { 21 | *handler 22 | } 23 | 24 | func (h *revokeRefreshTokenHandler) Revoke(ctx context.Context, req *charonrpc.RevokeRefreshTokenRequest) (*charonrpc.RevokeRefreshTokenResponse, error) { 25 | if len(req.Token) == 0 { 26 | return nil, grpcerr.E(codes.InvalidArgument, "refresh token cannot be disabled, invalid token: %s", req.Token) 27 | } 28 | if req.UserId == 0 { 29 | return nil, grpcerr.E(codes.InvalidArgument, "refresh token cannot be disabled, missing user id") 30 | } 31 | 32 | act, err := h.Actor(ctx) 33 | if err != nil { 34 | return nil, err 35 | } 36 | ent, err := h.repository.refreshToken.FindOneByTokenAndUserID(ctx, req.Token, req.UserId) 37 | if err != nil { 38 | if err == sql.ErrNoRows { 39 | return nil, grpcerr.E(codes.NotFound, "refresh token does not exists") 40 | } 41 | return nil, grpcerr.E(codes.Internal, "refresh token could not be retrieved", err) 42 | } 43 | if err = h.firewall(req, act, ent); err != nil { 44 | return nil, err 45 | } 46 | 47 | ent, err = h.repository.refreshToken.UpdateOneByToken(ctx, req.Token, &model.RefreshTokenPatch{ 48 | Revoked: ntypes.Bool{Bool: true, Valid: true}, 49 | }) 50 | if err != nil { 51 | if err == sql.ErrNoRows { 52 | return nil, grpcerr.E(codes.NotFound, "refresh token does not exists") 53 | } 54 | return nil, grpcerr.E(codes.Internal, "refresh token could not be disabled", err) 55 | } 56 | 57 | res, err := h.session.Delete(ctx, &mnemosynerpc.DeleteRequest{ 58 | SubjectId: session.ActorIDFromInt64(ent.UserID).String(), 59 | RefreshToken: req.Token, 60 | }) 61 | if err != nil { 62 | if status.Code(err) != codes.NotFound { 63 | return nil, grpcerr.E(codes.Internal, "session could not be removed", err) 64 | } 65 | } 66 | h.logger.Debug("refresh token corresponding sessions removed", zap.Int64("count", res.Value)) 67 | 68 | msg, err := mapping.ReverseRefreshToken(ent) 69 | if err != nil { 70 | return nil, grpcerr.E(codes.Internal, "refresh token mapping failure", err) 71 | } 72 | return &charonrpc.RevokeRefreshTokenResponse{ 73 | RefreshToken: msg, 74 | }, nil 75 | } 76 | 77 | func (h *revokeRefreshTokenHandler) firewall(req *charonrpc.RevokeRefreshTokenRequest, act *session.Actor, ent *model.RefreshTokenEntity) error { 78 | if act.User.IsSuperuser { 79 | return nil 80 | } 81 | if act.Permissions.Contains(charon.RefreshTokenCanRevokeAsStranger) { 82 | return nil 83 | } 84 | if act.Permissions.Contains(charon.RefreshTokenCanRevokeAsOwner) { 85 | if act.User.ID == ent.UserID { 86 | return nil 87 | } 88 | return grpcerr.E(codes.PermissionDenied, "refresh token cannot be revoked by stranger, missing permission") 89 | } 90 | return grpcerr.E(codes.PermissionDenied, "refresh token cannot be revoked, missing permission") 91 | } 92 | -------------------------------------------------------------------------------- /presentation.slide: -------------------------------------------------------------------------------- 1 | #+theme=black 2 | 3 | Charon 4 | Authorization and authentication service. 5 | 20:40 26 Mar 2018 6 | Tags: authorization, authentication, service, grpc 7 | 8 | Piotr Kowalczuk 9 | p.kowalczuk.priv@gmail.com 10 | https://gofunc.pl 11 | @kowalczuk_piotr 12 | 13 | * What Charon is? 14 | 15 | Charon is authorization and authentication service written in *Go* that expose language agnostic RPC API. 16 | 17 | First commit: *Apr* *19,* *2015* 18 | Latest version: *v0.16.3* 19 | 20 | .image data/logo/charon.png _ 300 21 | 22 | * Functional requirements 23 | 24 | *Authentication* 25 | 26 | - authentication based on *access* *token* 27 | - ability to obtain access token using *username/password* and/or *refresh* *token* 28 | 29 | *Authorization* 30 | 31 | - *RBAC* - Role based access control 32 | - *ABAC* - Attribute based access control _(simplified)_ 33 | 34 | *Other* 35 | 36 | - CRUD API 37 | - Backend for OAuth2 service 38 | 39 | * Technical requirements 40 | 41 | - Easy to deploy, operate and integrate (self-hosted). 42 | - Language agnostic API. 43 | - docker-compose and kubernetes ready. 44 | - Whitebox monitoring. 45 | 46 | * Available solutions 47 | 48 | none 49 | 50 | * Tech stack 51 | 52 | - Language: Go 53 | - API: RPC (using gRPC, it was not even 1.0.0 at a time) 54 | - Encoding: Protocol Buffers 55 | - Database: Postgres 56 | - Session management: Mnemosyne 57 | 58 | * Deployment 59 | 60 | .image data/deployment.png 550 _ 61 | 62 | * Model 63 | 64 | - `Permission` 65 | - `RefreshToken` 66 | 67 | - `User` 68 | - `UserGroups` _(transient_ _table)_ 69 | - `UserPermissions` _(transient_ _table)_ 70 | 71 | - `Group` 72 | - `GroupPermissions` _(transient_ _table)_ 73 | 74 | * Permissions 75 | Simple representation as a string, that can be also used as an OAuth2 scope: 76 | 77 | :: 78 | 79 | Examples: 80 | 81 | charon:user:can create 82 | news-service:comment:can modify as an owner 83 | asset-service:image:can retrieve if smaller than megabyte 84 | 85 | Management: 86 | 87 | .code pb/rpc/charond/v1/permission.pb.go /^type PermissionManagerServer interface/,/^}/ 88 | 89 | * Security Context 90 | 91 | type SecurityContext interface { 92 | context.Context 93 | oauth2.TokenSource 94 | // Actor ... 95 | Actor() (Actor, bool) 96 | // AccessToken ... 97 | AccessToken() (string, bool) 98 | } 99 | 100 | * Integration 101 | 102 | func securityContext(auth *charonc.Client, ctx context.Context) (charonc.SecurityContext, error) { 103 | if sctx, ok := ctx.(charonc.SecurityContext); ok { 104 | return sctx, nil 105 | } 106 | 107 | md, ok := metadata.FromIncomingContext(ctx) 108 | if !ok { 109 | return nil, errors.New("missing metadata in context, session token cannot be retrieved") 110 | } 111 | if len(md[mnemosyne.AccessTokenMetadataKey]) == 0 { 112 | return nil, errors.New("missing session token in metadata") 113 | } 114 | act, err := auth.Actor(ctx, md[mnemosyne.AccessTokenMetadataKey][0]) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | ctx = mnemosyne.NewAccessTokenContext(ctx, md[mnemosyne.AccessTokenMetadataKey][0]) 120 | ctx = charonc.NewActorContext(ctx, *act) 121 | 122 | return charonc.NewSecurityContext(ctx), nil 123 | } 124 | 125 | * Towards v1.0.0 126 | 127 | - Refresh token functionality is not ready yet (Apps?). 128 | - `charonc` package needs to be revisited (versioning of RPC API vs public code). 129 | - Remove experimental LDAP integration. 130 | - Documentation. 131 | 132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /internal/charond/handler_list_users.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/piotrkowalczuk/charon" 7 | charonrpc "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 8 | "github.com/piotrkowalczuk/charon/internal/grpcerr" 9 | "github.com/piotrkowalczuk/charon/internal/mapping" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/session" 12 | "github.com/piotrkowalczuk/ntypes" 13 | "github.com/piotrkowalczuk/qtypes" 14 | 15 | "google.golang.org/grpc/codes" 16 | ) 17 | 18 | type listUsersHandler struct { 19 | *handler 20 | } 21 | 22 | func (luh *listUsersHandler) List(ctx context.Context, req *charonrpc.ListUsersRequest) (*charonrpc.ListUsersResponse, error) { 23 | act, err := luh.Actor(ctx) 24 | if err != nil { 25 | return nil, err 26 | } 27 | if err = luh.firewall(req, act); err != nil { 28 | return nil, err 29 | } 30 | 31 | cri := &model.UserCriteria{ 32 | IsSuperuser: allocNilBool(req.IsSuperuser), 33 | IsStaff: allocNilBool(req.IsStaff), 34 | CreatedBy: req.CreatedBy, 35 | } 36 | 37 | if !act.User.IsSuperuser { 38 | cri.IsSuperuser = *ntypes.False() 39 | } 40 | if !act.Permissions.Contains(charon.UserCanRetrieveStaffAsStranger) { 41 | cri.IsStaff = *ntypes.False() 42 | } 43 | if act.Permissions.Contains(charon.UserCanRetrieveAsOwner, charon.UserCanRetrieveStaffAsOwner) { 44 | cri.CreatedBy = qtypes.EqualInt64(act.User.ID) 45 | } 46 | 47 | ents, err := luh.repository.user.Find(ctx, &model.UserFindExpr{ 48 | OrderBy: mapping.OrderBy(req.OrderBy), 49 | Offset: req.Offset.Int64Or(0), 50 | Limit: req.Limit.Int64Or(10), 51 | Where: cri, 52 | }) 53 | if err != nil { 54 | return nil, grpcerr.E(codes.Internal, "find users query failed", err) 55 | } 56 | return luh.response(ents) 57 | } 58 | 59 | func (luh *listUsersHandler) firewall(req *charonrpc.ListUsersRequest, act *session.Actor) error { 60 | if act.User.IsSuperuser { 61 | return nil 62 | } 63 | if req.IsSuperuser.BoolOr(false) { 64 | return grpcerr.E(codes.PermissionDenied, "only superuser is permitted to retrieve other superusers") 65 | } 66 | // STAFF USERS 67 | if req.IsStaff.BoolOr(false) { 68 | if req.CreatedBy != nil && req.CreatedBy.Value() == act.User.ID { 69 | if !act.Permissions.Contains(charon.UserCanRetrieveStaffAsStranger, charon.UserCanRetrieveStaffAsOwner) { 70 | return grpcerr.E(codes.PermissionDenied, "list of staff users cannot be retrieved as an owner, missing permission") 71 | } 72 | return nil 73 | } 74 | if !act.Permissions.Contains(charon.UserCanRetrieveStaffAsStranger) { 75 | return grpcerr.E(codes.PermissionDenied, "list of staff users cannot be retrieved as a stranger, missing permission") 76 | } 77 | return nil 78 | } 79 | // NON STAFF USERS 80 | if req.CreatedBy != nil && req.CreatedBy.Value() == act.User.ID { 81 | if !act.Permissions.Contains(charon.UserCanRetrieveAsStranger, charon.UserCanRetrieveAsOwner) { 82 | return grpcerr.E(codes.PermissionDenied, "list of users cannot be retrieved as an owner, missing permission") 83 | } 84 | return nil 85 | } 86 | if !act.Permissions.Contains(charon.UserCanRetrieveAsStranger) { 87 | return grpcerr.E(codes.PermissionDenied, "list of users cannot be retrieved as a stranger, missing permission") 88 | } 89 | return nil 90 | } 91 | 92 | func (luh *listUsersHandler) response(ents []*model.UserEntity) (*charonrpc.ListUsersResponse, error) { 93 | msg, err := mapping.ReverseUsers(ents) 94 | if err != nil { 95 | return nil, grpcerr.E(codes.Internal, "user reverse mapping failure") 96 | } 97 | return &charonrpc.ListUsersResponse{ 98 | Users: msg, 99 | }, nil 100 | } 101 | -------------------------------------------------------------------------------- /internal/charond/handler_test.go: -------------------------------------------------------------------------------- 1 | package charond 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "testing" 8 | 9 | "github.com/piotrkowalczuk/charon" 10 | "github.com/piotrkowalczuk/charon/internal/model" 11 | "github.com/piotrkowalczuk/charon/internal/model/modelmock" 12 | "github.com/piotrkowalczuk/charon/internal/session" 13 | "github.com/piotrkowalczuk/mnemosyne" 14 | "github.com/piotrkowalczuk/mnemosyne/mnemosynerpc" 15 | "github.com/piotrkowalczuk/mnemosyne/mnemosynetest" 16 | . "github.com/smartystreets/goconvey/convey" 17 | "github.com/stretchr/testify/mock" 18 | ) 19 | 20 | func TestHandler(t *testing.T) { 21 | t.SkipNow() 22 | var ( 23 | id int64 24 | ctx context.Context 25 | err error 26 | act *session.Actor 27 | tkn string 28 | ) 29 | 30 | Convey("retrieveActor", t, func() { 31 | userRepositoryMock := &modelmock.UserProvider{} 32 | permissionRepositoryMock := &modelmock.PermissionProvider{} 33 | sessionMock := &mnemosynetest.SessionManagerClient{} 34 | h := &handler{ 35 | session: sessionMock, 36 | } 37 | h.repository.user = userRepositoryMock 38 | h.repository.permission = permissionRepositoryMock 39 | 40 | Convey("As unauthenticated user", func() { 41 | ctx = context.Background() 42 | sessionMock.On("Context", mock.Anything, none(), mock.Anything). 43 | Return(nil, errors.New("mnemosyned: test error")). 44 | Once() 45 | 46 | Convey("Should return an error", func() { 47 | act, err = h.Actor(ctx) 48 | 49 | So(err, ShouldNotBeNil) 50 | So(act, ShouldBeNil) 51 | }) 52 | }) 53 | Convey("As authenticated user", func() { 54 | id = 7856282 55 | tkn = "0000000001hash" 56 | ctx = mnemosyne.NewAccessTokenContext(context.Background(), tkn) 57 | sessionMock.On("Context", ctx, none(), mock.Anything). 58 | Return(&mnemosynerpc.ContextResponse{ 59 | Session: &mnemosynerpc.Session{ 60 | AccessToken: tkn, 61 | SubjectId: session.ActorIDFromInt64(id).String(), 62 | }, 63 | }, nil). 64 | Once() 65 | 66 | Convey("When user exists", func() { 67 | userRepositoryMock.On("FindOneByID", mock.Anything, id). 68 | Return(&model.UserEntity{ID: id}, nil). 69 | Once() 70 | 71 | Convey("And it has some Permissions", func() { 72 | permissionRepositoryMock.On("FindByUserID", mock.Anything, id). 73 | Return([]*model.PermissionEntity{ 74 | { 75 | Subsystem: charon.PermissionCanRetrieve.Subsystem(), 76 | Module: charon.PermissionCanRetrieve.Module(), 77 | Action: charon.PermissionCanRetrieve.Action(), 78 | }, 79 | { 80 | Subsystem: charon.UserCanRetrieveAsOwner.Subsystem(), 81 | Module: charon.UserCanRetrieveAsOwner.Module(), 82 | Action: charon.UserCanRetrieveAsOwner.Action(), 83 | }, 84 | }, nil). 85 | Once() 86 | 87 | Convey("Then it should be retrieved without any error", func() { 88 | act, err = h.Actor(ctx) 89 | 90 | So(err, ShouldBeNil) 91 | So(act, ShouldNotBeNil) 92 | So(act.User.ID, ShouldEqual, id) 93 | So(act.Permissions, ShouldHaveLength, 2) 94 | }) 95 | }) 96 | Convey("And it has no Permissions", func() { 97 | permissionRepositoryMock.On("FindByUserID", mock.Anything, id). 98 | Return(nil, sql.ErrNoRows). 99 | Once() 100 | 101 | Convey("Then it should be retrieved without any error", func() { 102 | act, err = h.Actor(ctx) 103 | 104 | So(err, ShouldBeNil) 105 | So(act, ShouldNotBeNil) 106 | So(act.User.ID, ShouldEqual, id) 107 | So(act.Permissions, ShouldHaveLength, 0) 108 | }) 109 | }) 110 | }) 111 | }) 112 | }) 113 | } 114 | -------------------------------------------------------------------------------- /pb/rpc/charond/v1mock/permission_manager_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package charondmock 4 | 5 | import charond "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 6 | import context "context" 7 | import grpc "google.golang.org/grpc" 8 | import mock "github.com/stretchr/testify/mock" 9 | 10 | // PermissionManagerClient is an autogenerated mock type for the PermissionManagerClient type 11 | type PermissionManagerClient struct { 12 | mock.Mock 13 | } 14 | 15 | // Get provides a mock function with given fields: ctx, in, opts 16 | func (_m *PermissionManagerClient) Get(ctx context.Context, in *charond.GetPermissionRequest, opts ...grpc.CallOption) (*charond.GetPermissionResponse, error) { 17 | _va := make([]interface{}, len(opts)) 18 | for _i := range opts { 19 | _va[_i] = opts[_i] 20 | } 21 | var _ca []interface{} 22 | _ca = append(_ca, ctx, in) 23 | _ca = append(_ca, _va...) 24 | ret := _m.Called(_ca...) 25 | 26 | var r0 *charond.GetPermissionResponse 27 | if rf, ok := ret.Get(0).(func(context.Context, *charond.GetPermissionRequest, ...grpc.CallOption) *charond.GetPermissionResponse); ok { 28 | r0 = rf(ctx, in, opts...) 29 | } else { 30 | if ret.Get(0) != nil { 31 | r0 = ret.Get(0).(*charond.GetPermissionResponse) 32 | } 33 | } 34 | 35 | var r1 error 36 | if rf, ok := ret.Get(1).(func(context.Context, *charond.GetPermissionRequest, ...grpc.CallOption) error); ok { 37 | r1 = rf(ctx, in, opts...) 38 | } else { 39 | r1 = ret.Error(1) 40 | } 41 | 42 | return r0, r1 43 | } 44 | 45 | // List provides a mock function with given fields: ctx, in, opts 46 | func (_m *PermissionManagerClient) List(ctx context.Context, in *charond.ListPermissionsRequest, opts ...grpc.CallOption) (*charond.ListPermissionsResponse, error) { 47 | _va := make([]interface{}, len(opts)) 48 | for _i := range opts { 49 | _va[_i] = opts[_i] 50 | } 51 | var _ca []interface{} 52 | _ca = append(_ca, ctx, in) 53 | _ca = append(_ca, _va...) 54 | ret := _m.Called(_ca...) 55 | 56 | var r0 *charond.ListPermissionsResponse 57 | if rf, ok := ret.Get(0).(func(context.Context, *charond.ListPermissionsRequest, ...grpc.CallOption) *charond.ListPermissionsResponse); ok { 58 | r0 = rf(ctx, in, opts...) 59 | } else { 60 | if ret.Get(0) != nil { 61 | r0 = ret.Get(0).(*charond.ListPermissionsResponse) 62 | } 63 | } 64 | 65 | var r1 error 66 | if rf, ok := ret.Get(1).(func(context.Context, *charond.ListPermissionsRequest, ...grpc.CallOption) error); ok { 67 | r1 = rf(ctx, in, opts...) 68 | } else { 69 | r1 = ret.Error(1) 70 | } 71 | 72 | return r0, r1 73 | } 74 | 75 | // Register provides a mock function with given fields: ctx, in, opts 76 | func (_m *PermissionManagerClient) Register(ctx context.Context, in *charond.RegisterPermissionsRequest, opts ...grpc.CallOption) (*charond.RegisterPermissionsResponse, error) { 77 | _va := make([]interface{}, len(opts)) 78 | for _i := range opts { 79 | _va[_i] = opts[_i] 80 | } 81 | var _ca []interface{} 82 | _ca = append(_ca, ctx, in) 83 | _ca = append(_ca, _va...) 84 | ret := _m.Called(_ca...) 85 | 86 | var r0 *charond.RegisterPermissionsResponse 87 | if rf, ok := ret.Get(0).(func(context.Context, *charond.RegisterPermissionsRequest, ...grpc.CallOption) *charond.RegisterPermissionsResponse); ok { 88 | r0 = rf(ctx, in, opts...) 89 | } else { 90 | if ret.Get(0) != nil { 91 | r0 = ret.Get(0).(*charond.RegisterPermissionsResponse) 92 | } 93 | } 94 | 95 | var r1 error 96 | if rf, ok := ret.Get(1).(func(context.Context, *charond.RegisterPermissionsRequest, ...grpc.CallOption) error); ok { 97 | r1 = rf(ctx, in, opts...) 98 | } else { 99 | r1 = ret.Error(1) 100 | } 101 | 102 | return r0, r1 103 | } 104 | -------------------------------------------------------------------------------- /pb/rpc/charond/v1mock/refresh_token_manager_client.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package charondmock 4 | 5 | import charond "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 6 | import context "context" 7 | import grpc "google.golang.org/grpc" 8 | import mock "github.com/stretchr/testify/mock" 9 | 10 | // RefreshTokenManagerClient is an autogenerated mock type for the RefreshTokenManagerClient type 11 | type RefreshTokenManagerClient struct { 12 | mock.Mock 13 | } 14 | 15 | // Create provides a mock function with given fields: ctx, in, opts 16 | func (_m *RefreshTokenManagerClient) Create(ctx context.Context, in *charond.CreateRefreshTokenRequest, opts ...grpc.CallOption) (*charond.CreateRefreshTokenResponse, error) { 17 | _va := make([]interface{}, len(opts)) 18 | for _i := range opts { 19 | _va[_i] = opts[_i] 20 | } 21 | var _ca []interface{} 22 | _ca = append(_ca, ctx, in) 23 | _ca = append(_ca, _va...) 24 | ret := _m.Called(_ca...) 25 | 26 | var r0 *charond.CreateRefreshTokenResponse 27 | if rf, ok := ret.Get(0).(func(context.Context, *charond.CreateRefreshTokenRequest, ...grpc.CallOption) *charond.CreateRefreshTokenResponse); ok { 28 | r0 = rf(ctx, in, opts...) 29 | } else { 30 | if ret.Get(0) != nil { 31 | r0 = ret.Get(0).(*charond.CreateRefreshTokenResponse) 32 | } 33 | } 34 | 35 | var r1 error 36 | if rf, ok := ret.Get(1).(func(context.Context, *charond.CreateRefreshTokenRequest, ...grpc.CallOption) error); ok { 37 | r1 = rf(ctx, in, opts...) 38 | } else { 39 | r1 = ret.Error(1) 40 | } 41 | 42 | return r0, r1 43 | } 44 | 45 | // List provides a mock function with given fields: ctx, in, opts 46 | func (_m *RefreshTokenManagerClient) List(ctx context.Context, in *charond.ListRefreshTokensRequest, opts ...grpc.CallOption) (*charond.ListRefreshTokensResponse, error) { 47 | _va := make([]interface{}, len(opts)) 48 | for _i := range opts { 49 | _va[_i] = opts[_i] 50 | } 51 | var _ca []interface{} 52 | _ca = append(_ca, ctx, in) 53 | _ca = append(_ca, _va...) 54 | ret := _m.Called(_ca...) 55 | 56 | var r0 *charond.ListRefreshTokensResponse 57 | if rf, ok := ret.Get(0).(func(context.Context, *charond.ListRefreshTokensRequest, ...grpc.CallOption) *charond.ListRefreshTokensResponse); ok { 58 | r0 = rf(ctx, in, opts...) 59 | } else { 60 | if ret.Get(0) != nil { 61 | r0 = ret.Get(0).(*charond.ListRefreshTokensResponse) 62 | } 63 | } 64 | 65 | var r1 error 66 | if rf, ok := ret.Get(1).(func(context.Context, *charond.ListRefreshTokensRequest, ...grpc.CallOption) error); ok { 67 | r1 = rf(ctx, in, opts...) 68 | } else { 69 | r1 = ret.Error(1) 70 | } 71 | 72 | return r0, r1 73 | } 74 | 75 | // Revoke provides a mock function with given fields: ctx, in, opts 76 | func (_m *RefreshTokenManagerClient) Revoke(ctx context.Context, in *charond.RevokeRefreshTokenRequest, opts ...grpc.CallOption) (*charond.RevokeRefreshTokenResponse, error) { 77 | _va := make([]interface{}, len(opts)) 78 | for _i := range opts { 79 | _va[_i] = opts[_i] 80 | } 81 | var _ca []interface{} 82 | _ca = append(_ca, ctx, in) 83 | _ca = append(_ca, _va...) 84 | ret := _m.Called(_ca...) 85 | 86 | var r0 *charond.RevokeRefreshTokenResponse 87 | if rf, ok := ret.Get(0).(func(context.Context, *charond.RevokeRefreshTokenRequest, ...grpc.CallOption) *charond.RevokeRefreshTokenResponse); ok { 88 | r0 = rf(ctx, in, opts...) 89 | } else { 90 | if ret.Get(0) != nil { 91 | r0 = ret.Get(0).(*charond.RevokeRefreshTokenResponse) 92 | } 93 | } 94 | 95 | var r1 error 96 | if rf, ok := ret.Get(1).(func(context.Context, *charond.RevokeRefreshTokenRequest, ...grpc.CallOption) error); ok { 97 | r1 = rf(ctx, in, opts...) 98 | } else { 99 | r1 = ret.Error(1) 100 | } 101 | 102 | return r0, r1 103 | } 104 | -------------------------------------------------------------------------------- /pb/rpc/charond/v1/common.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // source: github.com/piotrkowalczuk/charon/pb/rpc/charond/v1/common.proto 3 | 4 | package charond // import "github.com/piotrkowalczuk/charon/pb/rpc/charond/v1" 5 | 6 | import proto "github.com/golang/protobuf/proto" 7 | import fmt "fmt" 8 | import math "math" 9 | 10 | // Reference imports to suppress errors if they are not otherwise used. 11 | var _ = proto.Marshal 12 | var _ = fmt.Errorf 13 | var _ = math.Inf 14 | 15 | // This is a compile-time assertion to ensure that this generated file 16 | // is compatible with the proto package it is being compiled against. 17 | // A compilation error at this line likely means your copy of the 18 | // proto package needs to be updated. 19 | const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package 20 | 21 | // Order represents single field within OrderBy clause. 22 | type Order struct { 23 | Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` 24 | Descending bool `protobuf:"varint,2,opt,name=descending,proto3" json:"descending,omitempty"` 25 | XXX_NoUnkeyedLiteral struct{} `json:"-"` 26 | XXX_unrecognized []byte `json:"-"` 27 | XXX_sizecache int32 `json:"-"` 28 | } 29 | 30 | func (m *Order) Reset() { *m = Order{} } 31 | func (m *Order) String() string { return proto.CompactTextString(m) } 32 | func (*Order) ProtoMessage() {} 33 | func (*Order) Descriptor() ([]byte, []int) { 34 | return fileDescriptor_common_6fa0804b816886fd, []int{0} 35 | } 36 | func (m *Order) XXX_Unmarshal(b []byte) error { 37 | return xxx_messageInfo_Order.Unmarshal(m, b) 38 | } 39 | func (m *Order) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { 40 | return xxx_messageInfo_Order.Marshal(b, m, deterministic) 41 | } 42 | func (dst *Order) XXX_Merge(src proto.Message) { 43 | xxx_messageInfo_Order.Merge(dst, src) 44 | } 45 | func (m *Order) XXX_Size() int { 46 | return xxx_messageInfo_Order.Size(m) 47 | } 48 | func (m *Order) XXX_DiscardUnknown() { 49 | xxx_messageInfo_Order.DiscardUnknown(m) 50 | } 51 | 52 | var xxx_messageInfo_Order proto.InternalMessageInfo 53 | 54 | func (m *Order) GetName() string { 55 | if m != nil { 56 | return m.Name 57 | } 58 | return "" 59 | } 60 | 61 | func (m *Order) GetDescending() bool { 62 | if m != nil { 63 | return m.Descending 64 | } 65 | return false 66 | } 67 | 68 | func init() { 69 | proto.RegisterType((*Order)(nil), "charon.rpc.charond.v1.Order") 70 | } 71 | 72 | func init() { 73 | proto.RegisterFile("github.com/piotrkowalczuk/charon/pb/rpc/charond/v1/common.proto", fileDescriptor_common_6fa0804b816886fd) 74 | } 75 | 76 | var fileDescriptor_common_6fa0804b816886fd = []byte{ 77 | // 174 bytes of a gzipped FileDescriptorProto 78 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xb2, 0x4f, 0xcf, 0x2c, 0xc9, 79 | 0x28, 0x4d, 0xd2, 0x4b, 0xce, 0xcf, 0xd5, 0x2f, 0xc8, 0xcc, 0x2f, 0x29, 0xca, 0xce, 0x2f, 0x4f, 80 | 0xcc, 0x49, 0xae, 0x2a, 0xcd, 0xd6, 0x4f, 0xce, 0x48, 0x2c, 0xca, 0xcf, 0xd3, 0x2f, 0x48, 0xd2, 81 | 0x2f, 0x2a, 0x48, 0x86, 0xf2, 0x52, 0xf4, 0xcb, 0x0c, 0xf5, 0x93, 0xf3, 0x73, 0x73, 0xf3, 0xf3, 82 | 0xf4, 0x0a, 0x8a, 0xf2, 0x4b, 0xf2, 0x85, 0x44, 0x21, 0x12, 0x7a, 0x45, 0x05, 0xc9, 0x7a, 0x50, 83 | 0x35, 0x7a, 0x65, 0x86, 0x4a, 0xd6, 0x5c, 0xac, 0xfe, 0x45, 0x29, 0xa9, 0x45, 0x42, 0x42, 0x5c, 84 | 0x2c, 0x79, 0x89, 0xb9, 0xa9, 0x12, 0x8c, 0x0a, 0x8c, 0x1a, 0x9c, 0x41, 0x60, 0xb6, 0x90, 0x1c, 85 | 0x17, 0x57, 0x4a, 0x6a, 0x71, 0x72, 0x6a, 0x5e, 0x4a, 0x66, 0x5e, 0xba, 0x04, 0x93, 0x02, 0xa3, 86 | 0x06, 0x47, 0x10, 0x92, 0x88, 0x53, 0x02, 0x97, 0x42, 0x72, 0x7e, 0xae, 0x1e, 0xcc, 0x69, 0xd8, 87 | 0x2c, 0x08, 0x60, 0x8c, 0xb2, 0x22, 0xdd, 0xe9, 0xd6, 0x50, 0x66, 0x12, 0x1b, 0xd8, 0xf1, 0xc6, 88 | 0x80, 0x00, 0x00, 0x00, 0xff, 0xff, 0xb2, 0x79, 0xac, 0x27, 0xff, 0x00, 0x00, 0x00, 89 | } 90 | -------------------------------------------------------------------------------- /internal/model/permission_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/piotrkowalczuk/charon" 9 | ) 10 | 11 | var ( 12 | permissionTestFixtures = []*PermissionEntity{ 13 | { 14 | Subsystem: "subsystem", 15 | Module: "module", 16 | Action: "action", 17 | }, 18 | { 19 | Module: "module", 20 | Action: "action", 21 | }, 22 | { 23 | Action: "action", 24 | }, 25 | } 26 | ) 27 | 28 | func TestPermissionRepository_FindOneByID(t *testing.T) { 29 | suite := &postgresSuite{} 30 | suite.setup(t) 31 | defer suite.teardown(t) 32 | 33 | for res := range loadPermissionFixtures(t, suite.repository.permission, permissionTestFixtures) { 34 | found, err := suite.repository.permission.FindOneByID(context.TODO(), res.got.ID) 35 | 36 | if err != nil { 37 | t.Errorf("permission cannot be found, unexpected error: %s", err.Error()) 38 | continue 39 | } 40 | 41 | if assert(t, found != nil, "permission was not found, nil object returned") { 42 | assertf(t, reflect.DeepEqual(res.got, *found), "created and retrieved entity should be equal, but its not\ncreated: %#v\nfounded: %#v", res.got, found) 43 | } 44 | } 45 | } 46 | 47 | func TestPermissionRepository_Register(t *testing.T) { 48 | suite := &postgresSuite{} 49 | suite.setup(t) 50 | defer suite.teardown(t) 51 | 52 | data := []struct { 53 | created, removed, untouched int64 54 | permissions charon.Permissions 55 | }{ 56 | { 57 | created: int64(len(charon.AllPermissions)), 58 | permissions: charon.AllPermissions, 59 | }, 60 | { 61 | untouched: int64(len(charon.AllPermissions)), 62 | permissions: charon.AllPermissions, 63 | }, 64 | { 65 | untouched: 1, 66 | removed: int64(len(charon.AllPermissions) - 1), 67 | permissions: charon.Permissions{ 68 | charon.UserCanCreate, 69 | }, 70 | }, 71 | { 72 | created: 1, 73 | removed: 1, 74 | permissions: charon.Permissions{ 75 | charon.Permission("charon:fakemodule:fakeaction"), 76 | }, 77 | }, 78 | { 79 | created: 1, 80 | permissions: charon.Permissions{ 81 | charon.Permission("fakesystem:fakemodule:fakeaction"), 82 | }, 83 | }, 84 | { 85 | removed: 1, 86 | created: int64(len(charon.AllPermissions)), 87 | permissions: charon.AllPermissions, 88 | }, 89 | } 90 | 91 | for i, d := range data { 92 | created, untouched, removed, err := suite.repository.permission.Register(context.TODO(), d.permissions) 93 | if err != nil { 94 | t.Fatalf("unexpected error: %s", err.Error()) 95 | } 96 | if created != d.created { 97 | t.Errorf("expected different number of created permissions, expected %d got %d for set %d", d.created, created, i) 98 | } 99 | if untouched != d.untouched { 100 | t.Errorf("expected different number of untouched permissions, expected %d got %d for set %d", d.untouched, untouched, i) 101 | } 102 | if removed != d.removed { 103 | t.Errorf("expected different number of removed permissions, expected %d got %d for set %d", d.removed, removed, i) 104 | } 105 | } 106 | } 107 | 108 | type permissionFixtures struct { 109 | got, given PermissionEntity 110 | } 111 | 112 | func loadPermissionFixtures(t *testing.T, r PermissionProvider, f []*PermissionEntity) chan permissionFixtures { 113 | data := make(chan permissionFixtures, 1) 114 | 115 | go func() { 116 | for _, given := range f { 117 | entity, err := r.Insert(context.TODO(), given) 118 | if err != nil { 119 | t.Errorf("permission cannot be created, unexpected error: %s", err.Error()) 120 | continue 121 | } else { 122 | t.Logf("permission has been created, got id %d", entity.ID) 123 | } 124 | 125 | data <- permissionFixtures{ 126 | got: *entity, 127 | given: *given, 128 | } 129 | } 130 | 131 | close(data) 132 | }() 133 | 134 | return data 135 | } 136 | -------------------------------------------------------------------------------- /internal/model/modelmock/user_groups_provider.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v1.0.0. DO NOT EDIT. 2 | 3 | package modelmock 4 | 5 | import context "context" 6 | import mock "github.com/stretchr/testify/mock" 7 | import model "github.com/piotrkowalczuk/charon/internal/model" 8 | 9 | // UserGroupsProvider is an autogenerated mock type for the UserGroupsProvider type 10 | type UserGroupsProvider struct { 11 | mock.Mock 12 | } 13 | 14 | // DeleteByUserID provides a mock function with given fields: ctx, id 15 | func (_m *UserGroupsProvider) DeleteByUserID(ctx context.Context, id int64) (int64, error) { 16 | ret := _m.Called(ctx, id) 17 | 18 | var r0 int64 19 | if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok { 20 | r0 = rf(ctx, id) 21 | } else { 22 | r0 = ret.Get(0).(int64) 23 | } 24 | 25 | var r1 error 26 | if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { 27 | r1 = rf(ctx, id) 28 | } else { 29 | r1 = ret.Error(1) 30 | } 31 | 32 | return r0, r1 33 | } 34 | 35 | // Exists provides a mock function with given fields: ctx, userID, groupID 36 | func (_m *UserGroupsProvider) Exists(ctx context.Context, userID int64, groupID int64) (bool, error) { 37 | ret := _m.Called(ctx, userID, groupID) 38 | 39 | var r0 bool 40 | if rf, ok := ret.Get(0).(func(context.Context, int64, int64) bool); ok { 41 | r0 = rf(ctx, userID, groupID) 42 | } else { 43 | r0 = ret.Get(0).(bool) 44 | } 45 | 46 | var r1 error 47 | if rf, ok := ret.Get(1).(func(context.Context, int64, int64) error); ok { 48 | r1 = rf(ctx, userID, groupID) 49 | } else { 50 | r1 = ret.Error(1) 51 | } 52 | 53 | return r0, r1 54 | } 55 | 56 | // Find provides a mock function with given fields: ctx, expr 57 | func (_m *UserGroupsProvider) Find(ctx context.Context, expr *model.UserGroupsFindExpr) ([]*model.UserGroupsEntity, error) { 58 | ret := _m.Called(ctx, expr) 59 | 60 | var r0 []*model.UserGroupsEntity 61 | if rf, ok := ret.Get(0).(func(context.Context, *model.UserGroupsFindExpr) []*model.UserGroupsEntity); ok { 62 | r0 = rf(ctx, expr) 63 | } else { 64 | if ret.Get(0) != nil { 65 | r0 = ret.Get(0).([]*model.UserGroupsEntity) 66 | } 67 | } 68 | 69 | var r1 error 70 | if rf, ok := ret.Get(1).(func(context.Context, *model.UserGroupsFindExpr) error); ok { 71 | r1 = rf(ctx, expr) 72 | } else { 73 | r1 = ret.Error(1) 74 | } 75 | 76 | return r0, r1 77 | } 78 | 79 | // Insert provides a mock function with given fields: ctx, ent 80 | func (_m *UserGroupsProvider) Insert(ctx context.Context, ent *model.UserGroupsEntity) (*model.UserGroupsEntity, error) { 81 | ret := _m.Called(ctx, ent) 82 | 83 | var r0 *model.UserGroupsEntity 84 | if rf, ok := ret.Get(0).(func(context.Context, *model.UserGroupsEntity) *model.UserGroupsEntity); ok { 85 | r0 = rf(ctx, ent) 86 | } else { 87 | if ret.Get(0) != nil { 88 | r0 = ret.Get(0).(*model.UserGroupsEntity) 89 | } 90 | } 91 | 92 | var r1 error 93 | if rf, ok := ret.Get(1).(func(context.Context, *model.UserGroupsEntity) error); ok { 94 | r1 = rf(ctx, ent) 95 | } else { 96 | r1 = ret.Error(1) 97 | } 98 | 99 | return r0, r1 100 | } 101 | 102 | // Set provides a mock function with given fields: ctx, userID, groupIDs 103 | func (_m *UserGroupsProvider) Set(ctx context.Context, userID int64, groupIDs []int64) (int64, int64, error) { 104 | ret := _m.Called(ctx, userID, groupIDs) 105 | 106 | var r0 int64 107 | if rf, ok := ret.Get(0).(func(context.Context, int64, []int64) int64); ok { 108 | r0 = rf(ctx, userID, groupIDs) 109 | } else { 110 | r0 = ret.Get(0).(int64) 111 | } 112 | 113 | var r1 int64 114 | if rf, ok := ret.Get(1).(func(context.Context, int64, []int64) int64); ok { 115 | r1 = rf(ctx, userID, groupIDs) 116 | } else { 117 | r1 = ret.Get(1).(int64) 118 | } 119 | 120 | var r2 error 121 | if rf, ok := ret.Get(2).(func(context.Context, int64, []int64) error); ok { 122 | r2 = rf(ctx, userID, groupIDs) 123 | } else { 124 | r2 = ret.Error(2) 125 | } 126 | 127 | return r0, r1, r2 128 | } 129 | --------------------------------------------------------------------------------