mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-04-22 11:09:02 -05:00
Merge branch 'master' into config-doc-descriptions
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!bin/
|
||||
@@ -0,0 +1,42 @@
|
||||
SHELL := bash
|
||||
NAME := graph
|
||||
|
||||
include ../../.make/recursion.mk
|
||||
|
||||
############ tooling ############
|
||||
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
|
||||
include ../../.bingo/Variables.mk
|
||||
endif
|
||||
|
||||
############ go tooling ############
|
||||
include ../../.make/go.mk
|
||||
|
||||
############ release ############
|
||||
include ../../.make/release.mk
|
||||
|
||||
############ docs generate ############
|
||||
include ../../.make/docs.mk
|
||||
|
||||
.PHONY: docs-generate
|
||||
docs-generate: config-docs-generate
|
||||
|
||||
############ generate ############
|
||||
include ../../.make/generate.mk
|
||||
|
||||
.PHONY: ci-go-generate
|
||||
ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this target
|
||||
$(MOCKERY) --dir pkg/service/v0 --case underscore --name GatewayClient
|
||||
$(MOCKERY) --dir pkg/service/v0 --case underscore --name HTTPClient
|
||||
$(MOCKERY) --dir pkg/service/v0 --case underscore --name Publisher
|
||||
$(MOCKERY) --srcpkg github.com/go-ldap/ldap/v3 --case underscore --filename ldapclient.go --name Client
|
||||
|
||||
|
||||
.PHONY: ci-node-generate
|
||||
ci-node-generate:
|
||||
|
||||
############ licenses ############
|
||||
.PHONY: ci-node-check-licenses
|
||||
ci-node-check-licenses:
|
||||
|
||||
.PHONY: ci-node-save-licenses
|
||||
ci-node-save-licenses:
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/command"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := command.Execute(defaults.DefaultConfig()); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM amd64/alpine:latest
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add ca-certificates mailcap && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
LABEL maintainer="ownCloud GmbH <devops@owncloud.com>" \
|
||||
org.label-schema.name="oCIS Graph" \
|
||||
org.label-schema.vendor="ownCloud GmbH" \
|
||||
org.label-schema.schema-version="1.0"
|
||||
|
||||
EXPOSE 9120 9124
|
||||
|
||||
ENTRYPOINT ["/usr/bin/ocis-graph"]
|
||||
CMD ["server"]
|
||||
|
||||
COPY bin/ocis-graph /usr/bin/ocis-graph
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM arm32v6/alpine:latest
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add ca-certificates mailcap && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
LABEL maintainer="ownCloud GmbH <devops@owncloud.com>" \
|
||||
org.label-schema.name="oCIS Graph" \
|
||||
org.label-schema.vendor="ownCloud GmbH" \
|
||||
org.label-schema.schema-version="1.0"
|
||||
|
||||
EXPOSE 9120 9124
|
||||
|
||||
ENTRYPOINT ["/usr/bin/ocis-graph"]
|
||||
CMD ["server"]
|
||||
|
||||
COPY bin/ocis-graph /usr/bin/ocis-graph
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM arm64v8/alpine:latest
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add ca-certificates mailcap && \
|
||||
rm -rf /var/cache/apk/* && \
|
||||
echo 'hosts: files dns' >| /etc/nsswitch.conf
|
||||
|
||||
LABEL maintainer="ownCloud GmbH <devops@owncloud.com>" \
|
||||
org.label-schema.name="oCIS Graph" \
|
||||
org.label-schema.vendor="ownCloud GmbH" \
|
||||
org.label-schema.schema-version="1.0"
|
||||
|
||||
EXPOSE 9120 9124
|
||||
|
||||
ENTRYPOINT ["/usr/bin/ocis-graph"]
|
||||
CMD ["server"]
|
||||
|
||||
COPY bin/ocis-graph /usr/bin/ocis-graph
|
||||
@@ -0,0 +1,22 @@
|
||||
image: owncloud/ocis-graph:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
|
||||
{{#if build.tags}}
|
||||
tags:
|
||||
{{#each build.tags}}
|
||||
- {{this}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
manifests:
|
||||
- image: owncloud/ocis-graph:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
|
||||
platform:
|
||||
architecture: amd64
|
||||
os: linux
|
||||
- image: owncloud/ocis-graph:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
|
||||
platform:
|
||||
architecture: arm64
|
||||
variant: v8
|
||||
os: linux
|
||||
- image: owncloud/ocis-graph:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
|
||||
platform:
|
||||
architecture: arm
|
||||
variant: v6
|
||||
os: linux
|
||||
@@ -0,0 +1,349 @@
|
||||
// Code generated by mockery v2.10.4. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
grpc "google.golang.org/grpc"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
)
|
||||
|
||||
// GatewayClient is an autogenerated mock type for the GatewayClient type
|
||||
type GatewayClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Authenticate provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) Authenticate(ctx context.Context, in *gatewayv1beta1.AuthenticateRequest, opts ...grpc.CallOption) (*gatewayv1beta1.AuthenticateResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *gatewayv1beta1.AuthenticateResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *gatewayv1beta1.AuthenticateRequest, ...grpc.CallOption) *gatewayv1beta1.AuthenticateResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*gatewayv1beta1.AuthenticateResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *gatewayv1beta1.AuthenticateRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// CreateStorageSpace provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) CreateStorageSpace(ctx context.Context, in *providerv1beta1.CreateStorageSpaceRequest, opts ...grpc.CallOption) (*providerv1beta1.CreateStorageSpaceResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *providerv1beta1.CreateStorageSpaceResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.CreateStorageSpaceRequest, ...grpc.CallOption) *providerv1beta1.CreateStorageSpaceResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*providerv1beta1.CreateStorageSpaceResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.CreateStorageSpaceRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DeleteStorageSpace provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) DeleteStorageSpace(ctx context.Context, in *providerv1beta1.DeleteStorageSpaceRequest, opts ...grpc.CallOption) (*providerv1beta1.DeleteStorageSpaceResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *providerv1beta1.DeleteStorageSpaceResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.DeleteStorageSpaceRequest, ...grpc.CallOption) *providerv1beta1.DeleteStorageSpaceResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*providerv1beta1.DeleteStorageSpaceResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.DeleteStorageSpaceRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetHome provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) GetHome(ctx context.Context, in *providerv1beta1.GetHomeRequest, opts ...grpc.CallOption) (*providerv1beta1.GetHomeResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *providerv1beta1.GetHomeResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.GetHomeRequest, ...grpc.CallOption) *providerv1beta1.GetHomeResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*providerv1beta1.GetHomeResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.GetHomeRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetPath provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) GetPath(ctx context.Context, in *providerv1beta1.GetPathRequest, opts ...grpc.CallOption) (*providerv1beta1.GetPathResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *providerv1beta1.GetPathResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.GetPathRequest, ...grpc.CallOption) *providerv1beta1.GetPathResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*providerv1beta1.GetPathResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.GetPathRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetQuota provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) GetQuota(ctx context.Context, in *gatewayv1beta1.GetQuotaRequest, opts ...grpc.CallOption) (*providerv1beta1.GetQuotaResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *providerv1beta1.GetQuotaResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *gatewayv1beta1.GetQuotaRequest, ...grpc.CallOption) *providerv1beta1.GetQuotaResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*providerv1beta1.GetQuotaResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *gatewayv1beta1.GetQuotaRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// InitiateFileDownload provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) InitiateFileDownload(ctx context.Context, in *providerv1beta1.InitiateFileDownloadRequest, opts ...grpc.CallOption) (*gatewayv1beta1.InitiateFileDownloadResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *gatewayv1beta1.InitiateFileDownloadResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.InitiateFileDownloadRequest, ...grpc.CallOption) *gatewayv1beta1.InitiateFileDownloadResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*gatewayv1beta1.InitiateFileDownloadResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.InitiateFileDownloadRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ListContainer provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) ListContainer(ctx context.Context, in *providerv1beta1.ListContainerRequest, opts ...grpc.CallOption) (*providerv1beta1.ListContainerResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *providerv1beta1.ListContainerResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ListContainerRequest, ...grpc.CallOption) *providerv1beta1.ListContainerResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*providerv1beta1.ListContainerResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ListContainerRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ListStorageSpaces provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) ListStorageSpaces(ctx context.Context, in *providerv1beta1.ListStorageSpacesRequest, opts ...grpc.CallOption) (*providerv1beta1.ListStorageSpacesResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *providerv1beta1.ListStorageSpacesResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.ListStorageSpacesRequest, ...grpc.CallOption) *providerv1beta1.ListStorageSpacesResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*providerv1beta1.ListStorageSpacesResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.ListStorageSpacesRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Stat provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) Stat(ctx context.Context, in *providerv1beta1.StatRequest, opts ...grpc.CallOption) (*providerv1beta1.StatResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *providerv1beta1.StatResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.StatRequest, ...grpc.CallOption) *providerv1beta1.StatResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*providerv1beta1.StatResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.StatRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// UpdateStorageSpace provides a mock function with given fields: ctx, in, opts
|
||||
func (_m *GatewayClient) UpdateStorageSpace(ctx context.Context, in *providerv1beta1.UpdateStorageSpaceRequest, opts ...grpc.CallOption) (*providerv1beta1.UpdateStorageSpaceResponse, error) {
|
||||
_va := make([]interface{}, len(opts))
|
||||
for _i := range opts {
|
||||
_va[_i] = opts[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, ctx, in)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 *providerv1beta1.UpdateStorageSpaceResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *providerv1beta1.UpdateStorageSpaceRequest, ...grpc.CallOption) *providerv1beta1.UpdateStorageSpaceResponse); ok {
|
||||
r0 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*providerv1beta1.UpdateStorageSpaceResponse)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *providerv1beta1.UpdateStorageSpaceRequest, ...grpc.CallOption) error); ok {
|
||||
r1 = rf(ctx, in, opts...)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Code generated by mockery v2.10.4. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
http "net/http"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// HTTPClient is an autogenerated mock type for the HTTPClient type
|
||||
type HTTPClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Do provides a mock function with given fields: req
|
||||
func (_m *HTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
ret := _m.Called(req)
|
||||
|
||||
var r0 *http.Response
|
||||
if rf, ok := ret.Get(0).(func(*http.Request) *http.Response); ok {
|
||||
r0 = rf(req)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*http.Response)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(*http.Request) error); ok {
|
||||
r1 = rf(req)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// Code generated by mockery v2.10.4. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
time "time"
|
||||
|
||||
tls "crypto/tls"
|
||||
)
|
||||
|
||||
// Client is an autogenerated mock type for the Client type
|
||||
type Client struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Add provides a mock function with given fields: _a0
|
||||
func (_m *Client) Add(_a0 *ldap.AddRequest) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*ldap.AddRequest) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Bind provides a mock function with given fields: username, password
|
||||
func (_m *Client) Bind(username string, password string) error {
|
||||
ret := _m.Called(username, password)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, string) error); ok {
|
||||
r0 = rf(username, password)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Close provides a mock function with given fields:
|
||||
func (_m *Client) Close() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// Compare provides a mock function with given fields: dn, attribute, value
|
||||
func (_m *Client) Compare(dn string, attribute string, value string) (bool, error) {
|
||||
ret := _m.Called(dn, attribute, value)
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func(string, string, string) bool); ok {
|
||||
r0 = rf(dn, attribute, value)
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
|
||||
r1 = rf(dn, attribute, value)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Del provides a mock function with given fields: _a0
|
||||
func (_m *Client) Del(_a0 *ldap.DelRequest) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*ldap.DelRequest) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ExternalBind provides a mock function with given fields:
|
||||
func (_m *Client) ExternalBind() error {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// IsClosing provides a mock function with given fields:
|
||||
func (_m *Client) IsClosing() bool {
|
||||
ret := _m.Called()
|
||||
|
||||
var r0 bool
|
||||
if rf, ok := ret.Get(0).(func() bool); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Get(0).(bool)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// Modify provides a mock function with given fields: _a0
|
||||
func (_m *Client) Modify(_a0 *ldap.ModifyRequest) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*ldap.ModifyRequest) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ModifyDN provides a mock function with given fields: _a0
|
||||
func (_m *Client) ModifyDN(_a0 *ldap.ModifyDNRequest) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*ldap.ModifyDNRequest) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ModifyWithResult provides a mock function with given fields: _a0
|
||||
func (_m *Client) ModifyWithResult(_a0 *ldap.ModifyRequest) (*ldap.ModifyResult, error) {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 *ldap.ModifyResult
|
||||
if rf, ok := ret.Get(0).(func(*ldap.ModifyRequest) *ldap.ModifyResult); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*ldap.ModifyResult)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(*ldap.ModifyRequest) error); ok {
|
||||
r1 = rf(_a0)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// PasswordModify provides a mock function with given fields: _a0
|
||||
func (_m *Client) PasswordModify(_a0 *ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 *ldap.PasswordModifyResult
|
||||
if rf, ok := ret.Get(0).(func(*ldap.PasswordModifyRequest) *ldap.PasswordModifyResult); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*ldap.PasswordModifyResult)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(*ldap.PasswordModifyRequest) error); ok {
|
||||
r1 = rf(_a0)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Search provides a mock function with given fields: _a0
|
||||
func (_m *Client) Search(_a0 *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 *ldap.SearchResult
|
||||
if rf, ok := ret.Get(0).(func(*ldap.SearchRequest) *ldap.SearchResult); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*ldap.SearchResult)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(*ldap.SearchRequest) error); ok {
|
||||
r1 = rf(_a0)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SearchWithPaging provides a mock function with given fields: searchRequest, pagingSize
|
||||
func (_m *Client) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error) {
|
||||
ret := _m.Called(searchRequest, pagingSize)
|
||||
|
||||
var r0 *ldap.SearchResult
|
||||
if rf, ok := ret.Get(0).(func(*ldap.SearchRequest, uint32) *ldap.SearchResult); ok {
|
||||
r0 = rf(searchRequest, pagingSize)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*ldap.SearchResult)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(*ldap.SearchRequest, uint32) error); ok {
|
||||
r1 = rf(searchRequest, pagingSize)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SetTimeout provides a mock function with given fields: _a0
|
||||
func (_m *Client) SetTimeout(_a0 time.Duration) {
|
||||
_m.Called(_a0)
|
||||
}
|
||||
|
||||
// SimpleBind provides a mock function with given fields: _a0
|
||||
func (_m *Client) SimpleBind(_a0 *ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error) {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 *ldap.SimpleBindResult
|
||||
if rf, ok := ret.Get(0).(func(*ldap.SimpleBindRequest) *ldap.SimpleBindResult); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*ldap.SimpleBindResult)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(*ldap.SimpleBindRequest) error); ok {
|
||||
r1 = rf(_a0)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Start provides a mock function with given fields:
|
||||
func (_m *Client) Start() {
|
||||
_m.Called()
|
||||
}
|
||||
|
||||
// StartTLS provides a mock function with given fields: _a0
|
||||
func (_m *Client) StartTLS(_a0 *tls.Config) error {
|
||||
ret := _m.Called(_a0)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(*tls.Config) error); ok {
|
||||
r0 = rf(_a0)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UnauthenticatedBind provides a mock function with given fields: username
|
||||
func (_m *Client) UnauthenticatedBind(username string) error {
|
||||
ret := _m.Called(username)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string) error); ok {
|
||||
r0 = rf(username)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Code generated by mockery v2.10.4. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
events "go-micro.dev/v4/events"
|
||||
)
|
||||
|
||||
// Publisher is an autogenerated mock type for the Publisher type
|
||||
type Publisher struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Publish provides a mock function with given fields: _a0, _a1, _a2
|
||||
func (_m *Publisher) Publish(_a0 string, _a1 interface{}, _a2 ...events.PublishOption) error {
|
||||
_va := make([]interface{}, len(_a2))
|
||||
for _i := range _a2 {
|
||||
_va[_i] = _a2[_i]
|
||||
}
|
||||
var _ca []interface{}
|
||||
_ca = append(_ca, _a0, _a1)
|
||||
_ca = append(_ca, _va...)
|
||||
ret := _m.Called(_ca...)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, interface{}, ...events.PublishOption) error); ok {
|
||||
r0 = rf(_a0, _a1, _a2...)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config/parser"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/logging"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// Health is the entrypoint for the health command.
|
||||
func Health(cfg *config.Config) *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "health",
|
||||
Usage: "check health status",
|
||||
Category: "info",
|
||||
Before: func(c *cli.Context) error {
|
||||
err := parser.ParseConfig(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("%v", err)
|
||||
}
|
||||
return err
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
logger := logging.Configure(cfg.Service.Name, cfg.Log)
|
||||
|
||||
resp, err := http.Get(
|
||||
fmt.Sprintf(
|
||||
"http://%s/healthz",
|
||||
cfg.Debug.Addr,
|
||||
),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Fatal().
|
||||
Err(err).
|
||||
Msg("Failed to request health check")
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Fatal().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health seems to be in bad state")
|
||||
}
|
||||
|
||||
logger.Debug().
|
||||
Int("code", resp.StatusCode).
|
||||
Msg("Health got a good state")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/clihelper"
|
||||
"github.com/thejerf/suture/v4"
|
||||
|
||||
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// GetCommands provides all commands for this service
|
||||
func GetCommands(cfg *config.Config) cli.Commands {
|
||||
return []*cli.Command{
|
||||
// start this service
|
||||
Server(cfg),
|
||||
|
||||
// interaction with this service
|
||||
|
||||
// infos about this service
|
||||
Health(cfg),
|
||||
Version(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// Execute is the entry point for the ocis-graph command.
|
||||
func Execute(cfg *config.Config) error {
|
||||
app := clihelper.DefaultApp(&cli.App{
|
||||
Name: "graph",
|
||||
Usage: "Serve Graph API for oCIS",
|
||||
Commands: GetCommands(cfg),
|
||||
})
|
||||
cli.HelpFlag = &cli.BoolFlag{
|
||||
Name: "help,h",
|
||||
Usage: "Show the help",
|
||||
}
|
||||
|
||||
return app.Run(os.Args)
|
||||
}
|
||||
|
||||
// SutureService allows for the graph command to be embedded and supervised by a suture supervisor tree.
|
||||
type SutureService struct {
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewSutureService creates a new graph.SutureService
|
||||
func NewSutureService(cfg *ociscfg.Config) suture.Service {
|
||||
cfg.Graph.Commons = cfg.Commons
|
||||
return SutureService{
|
||||
cfg: cfg.Graph,
|
||||
}
|
||||
}
|
||||
|
||||
func (s SutureService) Serve(ctx context.Context) error {
|
||||
s.cfg.Context = ctx
|
||||
if err := Execute(s.cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/oklog/run"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config/parser"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/logging"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/metrics"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/server/debug"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/server/http"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/tracing"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// Server is the entrypoint for the server command.
|
||||
func Server(cfg *config.Config) *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "server",
|
||||
Usage: fmt.Sprintf("start %s extension without runtime (unsupervised mode)", cfg.Service.Name),
|
||||
Category: "server",
|
||||
Before: func(c *cli.Context) error {
|
||||
err := parser.ParseConfig(cfg)
|
||||
if err != nil {
|
||||
fmt.Printf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return err
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
logger := logging.Configure(cfg.Service.Name, cfg.Log)
|
||||
err := tracing.Configure(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gr := run.Group{}
|
||||
ctx, cancel := func() (context.Context, context.CancelFunc) {
|
||||
if cfg.Context == nil {
|
||||
return context.WithCancel(context.Background())
|
||||
}
|
||||
return context.WithCancel(cfg.Context)
|
||||
}()
|
||||
mtrcs := metrics.New()
|
||||
|
||||
defer cancel()
|
||||
|
||||
mtrcs.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
{
|
||||
server, err := http.Server(
|
||||
http.Logger(logger),
|
||||
http.Context(ctx),
|
||||
http.Config(cfg),
|
||||
http.Metrics(mtrcs),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Info().Err(err).Str("transport", "http").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(func() error {
|
||||
return server.Run()
|
||||
}, func(_ error) {
|
||||
logger.Info().
|
||||
Str("transport", "http").
|
||||
Msg("Shutting down server")
|
||||
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
server, err := debug.Server(
|
||||
debug.Logger(logger),
|
||||
debug.Context(ctx),
|
||||
debug.Config(cfg),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(server.ListenAndServe, func(_ error) {
|
||||
_ = server.Shutdown(ctx)
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
return gr.Run()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
|
||||
tw "github.com/olekukonko/tablewriter"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// Version prints the service versions of all running instances.
|
||||
func Version(cfg *config.Config) *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "version",
|
||||
Usage: "print the version of this binary and the running extension instances",
|
||||
Category: "info",
|
||||
Action: func(c *cli.Context) error {
|
||||
fmt.Println("Version: " + version.GetString())
|
||||
fmt.Printf("Compiled: %s\n", version.Compiled())
|
||||
fmt.Println("")
|
||||
|
||||
reg := registry.GetRegistry()
|
||||
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
|
||||
if err != nil {
|
||||
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No running " + cfg.Service.Name + " service found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
table := tw.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Version", "Address", "Id"})
|
||||
table.SetAutoFormatHeaders(false)
|
||||
for _, s := range services {
|
||||
for _, n := range s.Nodes {
|
||||
table.Append([]string{s.Version, n.Address, n.Id})
|
||||
}
|
||||
}
|
||||
table.Render()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
)
|
||||
|
||||
// Config combines all available configuration parts.
|
||||
type Config struct {
|
||||
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
|
||||
|
||||
Service Service `yaml:"-"`
|
||||
|
||||
Tracing *Tracing `yaml:"tracing"`
|
||||
Log *Log `yaml:"log"`
|
||||
Debug Debug `yaml:"debug"`
|
||||
|
||||
HTTP HTTP `yaml:"http"`
|
||||
|
||||
Reva *Reva `yaml:"reva"`
|
||||
TokenManager *TokenManager `yaml:"token_manager"`
|
||||
|
||||
Spaces Spaces `yaml:"spaces"`
|
||||
Identity Identity `yaml:"identity"`
|
||||
Events Events `yaml:"events"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
|
||||
type Spaces struct {
|
||||
WebDavBase string `yaml:"webdav_base" env:"OCIS_URL;GRAPH_SPACES_WEBDAV_BASE" desc:"The public facing URL of WebDAV."`
|
||||
WebDavPath string `yaml:"webdav_path" env:"GRAPH_SPACES_WEBDAV_PATH" desc:"The WebDAV subpath for spaces."`
|
||||
DefaultQuota string `yaml:"default_quota" env:"GRAPH_SPACES_DEFAULT_QUOTA" desc:"The default quota in bytes."`
|
||||
Insecure bool `yaml:"insecure" env:"OCIS_INSECURE;GRAPH_SPACES_INSECURE" desc:"Allow insecure connetctions to the spaces."`
|
||||
ExtendedSpacePropertiesCacheTTL int `yaml:"extended_space_properties_cache_ttl" env:"GRAPH_SPACES_EXTENDED_SPACE_PROPERTIES_CACHE_TTL" desc:"Max TTL for the spaces property cache."`
|
||||
}
|
||||
|
||||
type LDAP struct {
|
||||
URI string `yaml:"uri" env:"LDAP_URI;GRAPH_LDAP_URI" desc:"URI of the LDAP Server to connect to. Supported URI schemes are 'ldaps://' and 'ldap://'"`
|
||||
CACert string `yaml:"cacert" env:"LDAP_CACERT;GRAPH_LDAP_CACERT" desc:"The certificate to verify TLS connections"`
|
||||
Insecure bool `yaml:"insecure" env:"LDAP_INSECURE;GRAPH_LDAP_INSECURE"`
|
||||
BindDN string `yaml:"bind_dn" env:"LDAP_BIND_DN;GRAPH_LDAP_BIND_DN"`
|
||||
BindPassword string `yaml:"bind_password" env:"LDAP_BIND_PASSWORD;GRAPH_LDAP_BIND_PASSWORD"`
|
||||
UseServerUUID bool `yaml:"use_server_uuid" env:"GRAPH_LDAP_SERVER_UUID"`
|
||||
WriteEnabled bool `yaml:"write_enabled" env:"GRAPH_LDAP_SERVER_WRITE_ENABLED"`
|
||||
|
||||
UserBaseDN string `yaml:"user_base_dn" env:"LDAP_USER_BASE_DN;GRAPH_LDAP_USER_BASE_DN"`
|
||||
UserSearchScope string `yaml:"user_search_scope" env:"LDAP_USER_SCOPE;GRAPH_LDAP_USER_SCOPE"`
|
||||
UserFilter string `yaml:"user_filter" env:"LDAP_USER_FILTER;GRAPH_LDAP_USER_FILTER"`
|
||||
UserObjectClass string `yaml:"user_objectclass" env:"LDAP_USER_OBJECTCLASS;GRAPH_LDAP_USER_OBJECTCLASS"`
|
||||
UserEmailAttribute string `yaml:"user_mail_attribute" env:"LDAP_USER_SCHEMA_MAIL;GRAPH_LDAP_USER_EMAIL_ATTRIBUTE"`
|
||||
UserDisplayNameAttribute string `yaml:"user_displayname_attribute" env:"LDAP_USER_SCHEMA_DISPLAY_NAME;GRAPH_LDAP_USER_DISPLAYNAME_ATTRIBUTE"`
|
||||
UserNameAttribute string `yaml:"user_name_attribute" env:"LDAP_USER_SCHEMA_USERNAME;GRAPH_LDAP_USER_NAME_ATTRIBUTE"`
|
||||
UserIDAttribute string `yaml:"user_id_attribute" env:"LDAP_USER_SCHEMA_ID;GRAPH_LDAP_USER_UID_ATTRIBUTE"`
|
||||
|
||||
GroupBaseDN string `yaml:"group_base_dn" env:"LDAP_GROUP_BASE_DN;GRAPH_LDAP_GROUP_BASE_DN"`
|
||||
GroupSearchScope string `yaml:"group_search_scope" env:"LDAP_GROUP_SCOPE;GRAPH_LDAP_GROUP_SEARCH_SCOPE"`
|
||||
GroupFilter string `yaml:"group_filter" env:"LDAP_GROUP_FILTER;GRAPH_LDAP_GROUP_FILTER"`
|
||||
GroupObjectClass string `yaml:"group_objectclass" env:"LDAP_GROUP_OBJECTCLASS;GRAPH_LDAP_GROUP_OBJECTCLASS"`
|
||||
GroupNameAttribute string `yaml:"group_name_attribute" env:"LDAP_GROUP_SCHEMA_GROUPNAME;GRAPH_LDAP_GROUP_NAME_ATTRIBUTE"`
|
||||
GroupIDAttribute string `yaml:"group_id_attribute" env:"LDAP_GROUP_SCHEMA_ID;GRAPH_LDAP_GROUP_ID_ATTRIBUTE"`
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
Backend string `yaml:"backend" env:"GRAPH_IDENTITY_BACKEND" desc:"The user identity backend to use, defaults to 'ldap', can be 'cs3'."`
|
||||
LDAP LDAP `yaml:"ldap"`
|
||||
}
|
||||
|
||||
// Events combines the configuration options for the event bus.
|
||||
type Events struct {
|
||||
Endpoint string `yaml:"endpoint" env:"GRAPH_EVENTS_ENDPOINT" desc:"the address of the streaming service"`
|
||||
Cluster string `yaml:"cluster" env:"GRAPH_EVENTS_CLUSTER" desc:"the clusterID of the streaming service. Mandatory when using nats"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Debug defines the available debug configuration.
|
||||
type Debug struct {
|
||||
Addr string `yaml:"addr" env:"GRAPH_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed."`
|
||||
Token string `yaml:"token" env:"GRAPH_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint"`
|
||||
Pprof bool `yaml:"pprof" env:"GRAPH_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling"`
|
||||
Zpages bool `yaml:"zpages" env:"GRAPH_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces."`
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/config/defaults"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
)
|
||||
|
||||
func FullDefaultConfig() *config.Config {
|
||||
cfg := DefaultConfig()
|
||||
EnsureDefaults(cfg)
|
||||
Sanitize(cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func DefaultConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Debug: config.Debug{
|
||||
Addr: "127.0.0.1:9124",
|
||||
Token: "",
|
||||
},
|
||||
HTTP: config.HTTP{
|
||||
Addr: "127.0.0.1:9120",
|
||||
Namespace: "com.owncloud.graph",
|
||||
Root: "/graph",
|
||||
},
|
||||
Service: config.Service{
|
||||
Name: "graph",
|
||||
},
|
||||
Reva: &config.Reva{
|
||||
Address: "127.0.0.1:9142",
|
||||
},
|
||||
Spaces: config.Spaces{
|
||||
WebDavBase: "https://localhost:9200",
|
||||
WebDavPath: "/dav/spaces/",
|
||||
DefaultQuota: "1000000000",
|
||||
Insecure: false,
|
||||
},
|
||||
Identity: config.Identity{
|
||||
Backend: "ldap",
|
||||
LDAP: config.LDAP{
|
||||
URI: "ldaps://localhost:9235",
|
||||
Insecure: false,
|
||||
CACert: path.Join(defaults.BaseDataPath(), "idm", "ldap.crt"),
|
||||
BindDN: "uid=libregraph,ou=sysusers,o=libregraph-idm",
|
||||
UseServerUUID: false,
|
||||
WriteEnabled: true,
|
||||
UserBaseDN: "ou=users,o=libregraph-idm",
|
||||
UserSearchScope: "sub",
|
||||
UserFilter: "",
|
||||
UserObjectClass: "inetOrgPerson",
|
||||
UserEmailAttribute: "mail",
|
||||
UserDisplayNameAttribute: "displayName",
|
||||
UserNameAttribute: "uid",
|
||||
// FIXME: switch this to some more widely available attribute by default
|
||||
// ideally this needs to be constant for the lifetime of a users
|
||||
UserIDAttribute: "owncloudUUID",
|
||||
GroupBaseDN: "ou=groups,o=libregraph-idm",
|
||||
GroupSearchScope: "sub",
|
||||
GroupFilter: "",
|
||||
GroupObjectClass: "groupOfNames",
|
||||
GroupNameAttribute: "cn",
|
||||
GroupIDAttribute: "owncloudUUID",
|
||||
},
|
||||
},
|
||||
Events: config.Events{
|
||||
Endpoint: "127.0.0.1:9233",
|
||||
Cluster: "ocis-cluster",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func EnsureDefaults(cfg *config.Config) {
|
||||
// provide with defaults for shared logging, since we need a valid destination address for BindEnv.
|
||||
if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil {
|
||||
cfg.Log = &config.Log{
|
||||
Level: cfg.Commons.Log.Level,
|
||||
Pretty: cfg.Commons.Log.Pretty,
|
||||
Color: cfg.Commons.Log.Color,
|
||||
File: cfg.Commons.Log.File,
|
||||
}
|
||||
} else if cfg.Log == nil {
|
||||
cfg.Log = &config.Log{}
|
||||
}
|
||||
// provide with defaults for shared tracing, since we need a valid destination address for BindEnv.
|
||||
if cfg.Tracing == nil && cfg.Commons != nil && cfg.Commons.Tracing != nil {
|
||||
cfg.Tracing = &config.Tracing{
|
||||
Enabled: cfg.Commons.Tracing.Enabled,
|
||||
Type: cfg.Commons.Tracing.Type,
|
||||
Endpoint: cfg.Commons.Tracing.Endpoint,
|
||||
Collector: cfg.Commons.Tracing.Collector,
|
||||
}
|
||||
} else if cfg.Tracing == nil {
|
||||
cfg.Tracing = &config.Tracing{}
|
||||
}
|
||||
|
||||
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
|
||||
cfg.TokenManager = &config.TokenManager{
|
||||
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
|
||||
}
|
||||
} else if cfg.TokenManager == nil {
|
||||
cfg.TokenManager = &config.TokenManager{}
|
||||
}
|
||||
}
|
||||
|
||||
func Sanitize(cfg *config.Config) {
|
||||
// sanitize config
|
||||
if cfg.HTTP.Root != "/" {
|
||||
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package config
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Addr string `yaml:"addr" env:"GRAPH_HTTP_ADDR" desc:"The bind address of the HTTP service."`
|
||||
Namespace string `yaml:"-"`
|
||||
Root string `yaml:"root" env:"GRAPH_HTTP_ROOT" desc:"The root path of the HTTP service."`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Log defines the available log configuration.
|
||||
type Log struct {
|
||||
Level string `mapstructure:"level" env:"OCIS_LOG_LEVEL;GRAPH_LOG_LEVEL" desc:"The log level. Valid values are: \"panic\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\"."`
|
||||
Pretty bool `mapstructure:"pretty" env:"OCIS_LOG_PRETTY;GRAPH_LOG_PRETTY" desc:"Activates pretty log output."`
|
||||
Color bool `mapstructure:"color" env:"OCIS_LOG_COLOR;GRAPH_LOG_COLOR" desc:"Activates colorized log output."`
|
||||
File string `mapstructure:"file" env:"OCIS_LOG_FILE;GRAPH_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set."`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/shared"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode"
|
||||
)
|
||||
|
||||
// ParseConfig loads configuration from known paths.
|
||||
func ParseConfig(cfg *config.Config) error {
|
||||
_, err := ociscfg.BindSourcesToStructs(cfg.Service.Name, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaults.EnsureDefaults(cfg)
|
||||
|
||||
// load all env variables relevant to the config in the current context.
|
||||
if err := envdecode.Decode(cfg); err != nil {
|
||||
// no environment variable set for this config is an expected "error"
|
||||
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
defaults.Sanitize(cfg)
|
||||
|
||||
return Validate(cfg)
|
||||
}
|
||||
|
||||
func Validate(cfg *config.Config) error {
|
||||
if cfg.TokenManager.JWTSecret == "" {
|
||||
return shared.MissingJWTTokenError(cfg.Service.Name)
|
||||
}
|
||||
|
||||
if cfg.Identity.Backend == "ldap" && cfg.Identity.LDAP.BindPassword == "" {
|
||||
return shared.MissingLDAPBindPassword(cfg.Service.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package config
|
||||
|
||||
// Reva defines all available REVA configuration.
|
||||
type Reva struct {
|
||||
Address string `yaml:"address" env:"REVA_GATEWAY" desc:"The CS3 gateway endpoint."`
|
||||
}
|
||||
|
||||
// TokenManager is the config for using the reva token manager
|
||||
type TokenManager struct {
|
||||
JWTSecret string `yaml:"jwt_secret" env:"OCIS_JWT_SECRET;GRAPH_JWT_SECRET" desc:"The secret to mint and validate jwt tokens."`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
// Service defines the available service configuration.
|
||||
type Service struct {
|
||||
Name string `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package config
|
||||
|
||||
// Tracing defines the available tracing configuration.
|
||||
type Tracing struct {
|
||||
Enabled bool `yaml:"enabled" env:"OCIS_TRACING_ENABLED;GRAPH_TRACING_ENABLED" desc:"Activates tracing."`
|
||||
Type string `yaml:"type" env:"OCIS_TRACING_TYPE;GRAPH_TRACING_TYPE" desc:"The type of tracing. Defaults to \"\", which is the same as \"jaeger\". Allowed tracing types are \"jaeger\" and \"\" as of now."`
|
||||
Endpoint string `yaml:"endpoint" env:"OCIS_TRACING_ENDPOINT;GRAPH_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent."`
|
||||
Collector string `yaml:"collector" env:"OCIS_TRACING_COLLECTOR;GRAPH_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset."`
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
)
|
||||
|
||||
type Backend interface {
|
||||
// CreateUser creates a given user in the identity backend.
|
||||
CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error)
|
||||
// DeleteUser deletes a given user, identified by username or id, from the backend
|
||||
DeleteUser(ctx context.Context, nameOrID string) error
|
||||
// UpdateUser applies changes to given user, identified by username or id
|
||||
UpdateUser(ctx context.Context, nameOrID string, user libregraph.User) (*libregraph.User, error)
|
||||
GetUser(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.User, error)
|
||||
GetUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.User, error)
|
||||
|
||||
// CreateGroup creates the supplied group in the identity backend.
|
||||
CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error)
|
||||
// DeleteGroup deletes a given group, identified by id
|
||||
DeleteGroup(ctx context.Context, id string) error
|
||||
GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error)
|
||||
GetGroups(ctx context.Context, queryParam url.Values) ([]*libregraph.Group, error)
|
||||
GetGroupMembers(ctx context.Context, id string) ([]*libregraph.User, error)
|
||||
// AddMembersToGroup adds new members (reference by a slice of IDs) to supplied group in the identity backend.
|
||||
AddMembersToGroup(ctx context.Context, groupID string, memberID []string) error
|
||||
// RemoveMemberFromGroup removes a single member (by ID) from a group
|
||||
RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error
|
||||
}
|
||||
|
||||
func CreateUserModelFromCS3(u *cs3.User) *libregraph.User {
|
||||
if u.Id == nil {
|
||||
u.Id = &cs3.UserId{}
|
||||
}
|
||||
return &libregraph.User{
|
||||
DisplayName: &u.DisplayName,
|
||||
Mail: &u.Mail,
|
||||
OnPremisesSamAccountName: &u.Username,
|
||||
Id: &u.Id.OpaqueId,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
cs3group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
|
||||
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
)
|
||||
|
||||
var (
|
||||
errNotImplemented = errorcode.New(errorcode.NotSupported, "not implemented")
|
||||
)
|
||||
|
||||
type CS3 struct {
|
||||
Config *config.Reva
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
// CreateUser implements the Backend Interface. It's currently not supported for the CS3 backend
|
||||
func (i *CS3) CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error) {
|
||||
return nil, errNotImplemented
|
||||
}
|
||||
|
||||
// DeleteUser implements the Backend Interface. It's currently not supported for the CS3 backend
|
||||
func (i *CS3) DeleteUser(ctx context.Context, nameOrID string) error {
|
||||
return errNotImplemented
|
||||
}
|
||||
|
||||
// UpdateUser implements the Backend Interface. It's currently not suported for the CS3 backend
|
||||
func (i *CS3) UpdateUser(ctx context.Context, nameOrID string, user libregraph.User) (*libregraph.User, error) {
|
||||
return nil, errNotImplemented
|
||||
}
|
||||
|
||||
func (i *CS3) GetUser(ctx context.Context, userID string, queryParam url.Values) (*libregraph.User, error) {
|
||||
client, err := pool.GetGatewayServiceClient(i.Config.Address)
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("could not get client")
|
||||
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
|
||||
}
|
||||
|
||||
res, err := client.GetUserByClaim(ctx, &cs3user.GetUserByClaimRequest{
|
||||
Claim: "userid", // FIXME add consts to reva
|
||||
Value: userID,
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
i.Logger.Error().Err(err).Str("userid", userID).Msg("error sending get user by claim id grpc request")
|
||||
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
|
||||
case res.Status.Code != cs3rpc.Code_CODE_OK:
|
||||
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, res.Status.Message)
|
||||
}
|
||||
i.Logger.Error().Err(err).Str("userid", userID).Msg("error sending get user by claim id grpc request")
|
||||
return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
|
||||
}
|
||||
return CreateUserModelFromCS3(res.User), nil
|
||||
}
|
||||
|
||||
func (i *CS3) GetUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.User, error) {
|
||||
client, err := pool.GetGatewayServiceClient(i.Config.Address)
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("could not get client")
|
||||
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
|
||||
}
|
||||
|
||||
search := queryParam.Get("search")
|
||||
if search == "" {
|
||||
search = queryParam.Get("$search")
|
||||
}
|
||||
|
||||
res, err := client.FindUsers(ctx, &cs3user.FindUsersRequest{
|
||||
// FIXME presence match is currently not implemented, an empty search currently leads to
|
||||
// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
|
||||
Filter: search,
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
i.Logger.Error().Err(err).Str("search", search).Msg("error sending find users grpc request")
|
||||
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
|
||||
case res.Status.Code != cs3rpc.Code_CODE_OK:
|
||||
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, res.Status.Message)
|
||||
}
|
||||
i.Logger.Error().Err(err).Str("search", search).Msg("error sending find users grpc request")
|
||||
return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
|
||||
}
|
||||
|
||||
users := make([]*libregraph.User, 0, len(res.Users))
|
||||
|
||||
for _, user := range res.Users {
|
||||
users = append(users, CreateUserModelFromCS3(user))
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (i *CS3) GetGroups(ctx context.Context, queryParam url.Values) ([]*libregraph.Group, error) {
|
||||
client, err := pool.GetGatewayServiceClient(i.Config.Address)
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("could not get client")
|
||||
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
|
||||
}
|
||||
|
||||
search := queryParam.Get("search")
|
||||
if search == "" {
|
||||
search = queryParam.Get("$search")
|
||||
}
|
||||
|
||||
res, err := client.FindGroups(ctx, &cs3group.FindGroupsRequest{
|
||||
// FIXME presence match is currently not implemented, an empty search currently leads to
|
||||
// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
|
||||
Filter: search,
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
i.Logger.Error().Err(err).Str("search", search).Msg("error sending find groups grpc request")
|
||||
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
|
||||
case res.Status.Code != cs3rpc.Code_CODE_OK:
|
||||
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, res.Status.Message)
|
||||
}
|
||||
i.Logger.Error().Err(err).Str("search", search).Msg("error sending find groups grpc request")
|
||||
return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
|
||||
}
|
||||
|
||||
groups := make([]*libregraph.Group, 0, len(res.Groups))
|
||||
|
||||
for _, group := range res.Groups {
|
||||
groups = append(groups, createGroupModelFromCS3(group))
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// CreateGroup implements the Backend Interface. It's currently not supported for the CS3 backend
|
||||
func (i *CS3) CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error) {
|
||||
return nil, errorcode.New(errorcode.NotSupported, "not implemented")
|
||||
}
|
||||
|
||||
func (i *CS3) GetGroup(ctx context.Context, groupID string, queryParam url.Values) (*libregraph.Group, error) {
|
||||
client, err := pool.GetGatewayServiceClient(i.Config.Address)
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("could not get client")
|
||||
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
|
||||
}
|
||||
|
||||
res, err := client.GetGroupByClaim(ctx, &cs3group.GetGroupByClaimRequest{
|
||||
Claim: "groupid", // FIXME add consts to reva
|
||||
Value: groupID,
|
||||
})
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
i.Logger.Error().Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request")
|
||||
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
|
||||
case res.Status.Code != cs3rpc.Code_CODE_OK:
|
||||
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, res.Status.Message)
|
||||
}
|
||||
i.Logger.Error().Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request")
|
||||
return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
|
||||
}
|
||||
|
||||
return createGroupModelFromCS3(res.Group), nil
|
||||
}
|
||||
|
||||
// DeleteGroup implements the Backend Interface. It's currently not supported for the CS3 backend
|
||||
func (i *CS3) DeleteGroup(ctx context.Context, id string) error {
|
||||
return errorcode.New(errorcode.NotSupported, "not implemented")
|
||||
}
|
||||
|
||||
// GetGroupMembers implements the Backend Interface. It's currently not supported for the CS3 backend
|
||||
func (i *CS3) GetGroupMembers(ctx context.Context, groupID string) ([]*libregraph.User, error) {
|
||||
return nil, errorcode.New(errorcode.NotSupported, "not implemented")
|
||||
}
|
||||
|
||||
// AddMembersToGroup implements the Backend Interface. It's currently not supported for the CS3 backend
|
||||
func (i *CS3) AddMembersToGroup(ctx context.Context, groupID string, memberID []string) error {
|
||||
return errorcode.New(errorcode.NotSupported, "not implemented")
|
||||
}
|
||||
|
||||
// RemoveMemberFromGroup implements the Backend Interface. It's currently not supported for the CS3 backend
|
||||
func (i *CS3) RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error {
|
||||
return errorcode.New(errorcode.NotSupported, "not implemented")
|
||||
}
|
||||
|
||||
func createGroupModelFromCS3(g *cs3group.Group) *libregraph.Group {
|
||||
if g.Id == nil {
|
||||
g.Id = &cs3group.GroupId{}
|
||||
}
|
||||
return &libregraph.Group{
|
||||
Id: &g.Id.OpaqueId,
|
||||
OnPremisesDomainName: &g.Id.Idp,
|
||||
OnPremisesSamAccountName: &g.GroupName,
|
||||
DisplayName: &g.DisplayName,
|
||||
Mail: &g.Mail,
|
||||
// TODO when to fetch and expand memberof, usernames or ids?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/gofrs/uuid"
|
||||
ldapdn "github.com/libregraph/idm/pkg/ldapdn"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
var (
|
||||
errReadOnly = errorcode.New(errorcode.NotAllowed, "server is configured read-only")
|
||||
errNotFound = errorcode.New(errorcode.ItemNotFound, "not found")
|
||||
)
|
||||
|
||||
type LDAP struct {
|
||||
useServerUUID bool
|
||||
writeEnabled bool
|
||||
|
||||
userBaseDN string
|
||||
userFilter string
|
||||
userObjectClass string
|
||||
userScope int
|
||||
userAttributeMap userAttributeMap
|
||||
|
||||
groupBaseDN string
|
||||
groupFilter string
|
||||
groupObjectClass string
|
||||
groupScope int
|
||||
groupAttributeMap groupAttributeMap
|
||||
|
||||
logger *log.Logger
|
||||
conn ldap.Client
|
||||
}
|
||||
|
||||
type userAttributeMap struct {
|
||||
displayName string
|
||||
id string
|
||||
mail string
|
||||
userName string
|
||||
}
|
||||
|
||||
type groupAttributeMap struct {
|
||||
name string
|
||||
id string
|
||||
member string
|
||||
memberSyntax string
|
||||
}
|
||||
|
||||
func NewLDAPBackend(lc ldap.Client, config config.LDAP, logger *log.Logger) (*LDAP, error) {
|
||||
if config.UserDisplayNameAttribute == "" || config.UserIDAttribute == "" ||
|
||||
config.UserEmailAttribute == "" || config.UserNameAttribute == "" {
|
||||
return nil, errors.New("invalid user attribute mappings")
|
||||
}
|
||||
uam := userAttributeMap{
|
||||
displayName: config.UserDisplayNameAttribute,
|
||||
id: config.UserIDAttribute,
|
||||
mail: config.UserEmailAttribute,
|
||||
userName: config.UserNameAttribute,
|
||||
}
|
||||
|
||||
if config.GroupNameAttribute == "" || config.GroupIDAttribute == "" {
|
||||
return nil, errors.New("invalid group attribute mappings")
|
||||
}
|
||||
gam := groupAttributeMap{
|
||||
name: config.GroupNameAttribute,
|
||||
id: config.GroupIDAttribute,
|
||||
member: "member",
|
||||
memberSyntax: "dn",
|
||||
}
|
||||
|
||||
var userScope, groupScope int
|
||||
var err error
|
||||
if userScope, err = stringToScope(config.UserSearchScope); err != nil {
|
||||
return nil, fmt.Errorf("error configuring user scope: %w", err)
|
||||
}
|
||||
|
||||
if groupScope, err = stringToScope(config.GroupSearchScope); err != nil {
|
||||
return nil, fmt.Errorf("error configuring group scope: %w", err)
|
||||
}
|
||||
|
||||
return &LDAP{
|
||||
useServerUUID: config.UseServerUUID,
|
||||
userBaseDN: config.UserBaseDN,
|
||||
userFilter: config.UserFilter,
|
||||
userObjectClass: config.UserObjectClass,
|
||||
userScope: userScope,
|
||||
userAttributeMap: uam,
|
||||
groupBaseDN: config.GroupBaseDN,
|
||||
groupFilter: config.GroupFilter,
|
||||
groupObjectClass: config.GroupObjectClass,
|
||||
groupScope: groupScope,
|
||||
groupAttributeMap: gam,
|
||||
logger: logger,
|
||||
conn: lc,
|
||||
writeEnabled: config.WriteEnabled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateUser implements the Backend Interface. It converts the libregraph.User into an
|
||||
// LDAP User Entry (using the inetOrgPerson LDAP Objectclass) add adds that to the
|
||||
// configured LDAP server
|
||||
func (i *LDAP) CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error) {
|
||||
if !i.writeEnabled {
|
||||
return nil, errReadOnly
|
||||
}
|
||||
ar := ldap.AddRequest{
|
||||
DN: fmt.Sprintf("uid=%s,%s", *user.OnPremisesSamAccountName, i.userBaseDN),
|
||||
Attributes: []ldap.Attribute{
|
||||
// inetOrgPerson requires "cn"
|
||||
{
|
||||
Type: "cn",
|
||||
Vals: []string{*user.OnPremisesSamAccountName},
|
||||
},
|
||||
{
|
||||
Type: i.userAttributeMap.mail,
|
||||
Vals: []string{*user.Mail},
|
||||
},
|
||||
{
|
||||
Type: i.userAttributeMap.userName,
|
||||
Vals: []string{*user.OnPremisesSamAccountName},
|
||||
},
|
||||
{
|
||||
Type: i.userAttributeMap.displayName,
|
||||
Vals: []string{*user.DisplayName},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
objectClasses := []string{"inetOrgPerson", "organizationalPerson", "person", "top"}
|
||||
|
||||
if user.PasswordProfile != nil && user.PasswordProfile.Password != nil {
|
||||
// TODO? This relies to the LDAP server to properly hash the password.
|
||||
// We might want to add support for the Password Modify LDAP Extended
|
||||
// Operation for servers that implement it. (Or implement client-side
|
||||
// hashing here.
|
||||
ar.Attribute("userPassword", []string{*user.PasswordProfile.Password})
|
||||
}
|
||||
if !i.useServerUUID {
|
||||
ar.Attribute("owncloudUUID", []string{uuid.Must(uuid.NewV4()).String()})
|
||||
objectClasses = append(objectClasses, "owncloud")
|
||||
}
|
||||
ar.Attribute("objectClass", objectClasses)
|
||||
|
||||
// inetOrgPerson requires "sn" to be set. Set it to the Username if
|
||||
// Surname is not set in the Request
|
||||
var sn string
|
||||
if user.Surname != nil && *user.Surname != "" {
|
||||
sn = *user.Surname
|
||||
} else {
|
||||
sn = *user.OnPremisesSamAccountName
|
||||
}
|
||||
ar.Attribute("sn", []string{sn})
|
||||
|
||||
if err := i.conn.Add(&ar); err != nil {
|
||||
var lerr *ldap.Error
|
||||
i.logger.Debug().Err(err).Msg("error adding user")
|
||||
if errors.As(err, &lerr) {
|
||||
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
|
||||
err = errorcode.New(errorcode.NameAlreadyExists, lerr.Error())
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read back user from LDAP to get the generated UUID
|
||||
e, err := i.getUserByDN(ar.DN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return i.createUserModelFromLDAP(e), nil
|
||||
}
|
||||
|
||||
// DeleteUser implements the Backend Interface. It permanently deletes a User identified
|
||||
// by name or id from the LDAP server
|
||||
func (i *LDAP) DeleteUser(ctx context.Context, nameOrID string) error {
|
||||
if !i.writeEnabled {
|
||||
return errReadOnly
|
||||
}
|
||||
e, err := i.getLDAPUserByNameOrID(nameOrID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dr := ldap.DelRequest{DN: e.DN}
|
||||
if err = i.conn.Del(&dr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find all the groups that this user was a member of and remove it from there
|
||||
groupEntries, err := i.getLDAPGroupsByFilter(fmt.Sprintf("(%s=%s)", i.groupAttributeMap.member, e.DN), true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, group := range groupEntries {
|
||||
i.logger.Debug().Str("group", group.DN).Str("user", e.DN).Msg("Cleaning up group membership")
|
||||
|
||||
if mr, err := i.removeMemberFromGroupEntry(group, e.DN); err == nil && mr != nil {
|
||||
if err = i.conn.Modify(mr); err != nil {
|
||||
// Errors when deleting the memberships are only logged as warnings but not returned
|
||||
// to the user as we already successfully deleted the users itself
|
||||
i.logger.Warn().Str("group", group.DN).Str("user", e.DN).Err(err).Msg("failed to remove member")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUser implements the Backend Interface for the LDAP Backend
|
||||
func (i *LDAP) UpdateUser(ctx context.Context, nameOrID string, user libregraph.User) (*libregraph.User, error) {
|
||||
if !i.writeEnabled {
|
||||
return nil, errReadOnly
|
||||
}
|
||||
e, err := i.getLDAPUserByNameOrID(nameOrID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Don't allow updates of the ID
|
||||
if user.Id != nil && *user.Id != "" {
|
||||
if e.GetEqualFoldAttributeValue(i.userAttributeMap.id) != *user.Id {
|
||||
return nil, errorcode.New(errorcode.NotAllowed, "changing the UserId is not allowed")
|
||||
}
|
||||
}
|
||||
// TODO: In order to allow updating the user name we'd need to issue a ModRDN operation
|
||||
// As we currently using uid as the naming Attribute for the user entries. (Do we even
|
||||
// want to allow changing the user name?). For now just disallow it.
|
||||
if user.OnPremisesSamAccountName != nil && *user.OnPremisesSamAccountName != "" {
|
||||
if e.GetEqualFoldAttributeValue(i.userAttributeMap.userName) != *user.OnPremisesSamAccountName {
|
||||
return nil, errorcode.New(errorcode.NotSupported, "changing the user name is currently not supported")
|
||||
}
|
||||
}
|
||||
|
||||
mr := ldap.ModifyRequest{DN: e.DN}
|
||||
if user.DisplayName != nil && *user.DisplayName != "" {
|
||||
if e.GetEqualFoldAttributeValue(i.userAttributeMap.displayName) != *user.DisplayName {
|
||||
mr.Replace(i.userAttributeMap.displayName, []string{*user.DisplayName})
|
||||
}
|
||||
}
|
||||
if user.Mail != nil && *user.Mail != "" {
|
||||
if e.GetEqualFoldAttributeValue(i.userAttributeMap.mail) != *user.Mail {
|
||||
mr.Replace(i.userAttributeMap.mail, []string{*user.Mail})
|
||||
}
|
||||
}
|
||||
if user.PasswordProfile != nil && user.PasswordProfile.Password != nil && *user.PasswordProfile.Password != "" {
|
||||
// password are hashed server side there is no need to check if the new password
|
||||
// is actually different from the old one.
|
||||
mr.Replace("userPassword", []string{*user.PasswordProfile.Password})
|
||||
}
|
||||
|
||||
if err := i.conn.Modify(&mr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read back user from LDAP to get the generated UUID
|
||||
e, err = i.getUserByDN(e.DN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return i.createUserModelFromLDAP(e), nil
|
||||
}
|
||||
|
||||
func (i *LDAP) getUserByDN(dn string) (*ldap.Entry, error) {
|
||||
attrs := []string{
|
||||
i.userAttributeMap.displayName,
|
||||
i.userAttributeMap.id,
|
||||
i.userAttributeMap.mail,
|
||||
i.userAttributeMap.userName,
|
||||
}
|
||||
return i.getEntryByDN(dn, attrs)
|
||||
}
|
||||
|
||||
func (i *LDAP) getGroupByDN(dn string) (*ldap.Entry, error) {
|
||||
attrs := []string{
|
||||
i.groupAttributeMap.id,
|
||||
i.groupAttributeMap.name,
|
||||
}
|
||||
return i.getEntryByDN(dn, attrs)
|
||||
}
|
||||
|
||||
func (i *LDAP) getEntryByDN(dn string, attrs []string) (*ldap.Entry, error) {
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false,
|
||||
"(objectclass=*)",
|
||||
attrs,
|
||||
nil,
|
||||
)
|
||||
|
||||
i.logger.Debug().Str("backend", "ldap").Str("dn", dn).Msg("Search user by DN")
|
||||
res, err := i.conn.Search(searchRequest)
|
||||
|
||||
if err != nil {
|
||||
i.logger.Error().Err(err).Str("backend", "ldap").Str("dn", dn).Msg("Search user by DN failed")
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return nil, errNotFound
|
||||
}
|
||||
|
||||
return res.Entries[0], nil
|
||||
}
|
||||
|
||||
func (i *LDAP) getLDAPUserByID(id string) (*ldap.Entry, error) {
|
||||
id = ldap.EscapeFilter(id)
|
||||
filter := fmt.Sprintf("(%s=%s)", i.userAttributeMap.id, id)
|
||||
return i.getLDAPUserByFilter(filter)
|
||||
}
|
||||
|
||||
func (i *LDAP) getLDAPUserByNameOrID(nameOrID string) (*ldap.Entry, error) {
|
||||
nameOrID = ldap.EscapeFilter(nameOrID)
|
||||
filter := fmt.Sprintf("(|(%s=%s)(%s=%s))", i.userAttributeMap.userName, nameOrID, i.userAttributeMap.id, nameOrID)
|
||||
return i.getLDAPUserByFilter(filter)
|
||||
}
|
||||
|
||||
func (i *LDAP) getLDAPUserByFilter(filter string) (*ldap.Entry, error) {
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
i.userBaseDN, i.userScope, ldap.NeverDerefAliases, 1, 0, false,
|
||||
fmt.Sprintf("(&%s(objectClass=%s)%s)", i.userFilter, i.userObjectClass, filter),
|
||||
[]string{
|
||||
i.userAttributeMap.displayName,
|
||||
i.userAttributeMap.id,
|
||||
i.userAttributeMap.mail,
|
||||
i.userAttributeMap.userName,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
i.logger.Debug().Str("backend", "ldap").Msgf("Search %s", i.userBaseDN)
|
||||
res, err := i.conn.Search(searchRequest)
|
||||
|
||||
if err != nil {
|
||||
var errmsg string
|
||||
if lerr, ok := err.(*ldap.Error); ok {
|
||||
if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
|
||||
errmsg = fmt.Sprintf("too many results searching for user '%s'", filter)
|
||||
i.logger.Debug().Str("backend", "ldap").Err(lerr).
|
||||
Str("userfilter", filter).Msg("too many results searching for user")
|
||||
}
|
||||
}
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return nil, errNotFound
|
||||
}
|
||||
|
||||
return res.Entries[0], nil
|
||||
}
|
||||
|
||||
func (i *LDAP) GetUser(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.User, error) {
|
||||
i.logger.Debug().Str("backend", "ldap").Msg("GetUser")
|
||||
e, err := i.getLDAPUserByNameOrID(nameOrID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sel := strings.Split(queryParam.Get("$select"), ",")
|
||||
exp := strings.Split(queryParam.Get("$expand"), ",")
|
||||
u := i.createUserModelFromLDAP(e)
|
||||
if slices.Contains(sel, "memberOf") || slices.Contains(exp, "memberOf") {
|
||||
userGroups, err := i.getGroupsForUser(e.DN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(userGroups) > 0 {
|
||||
groups := make([]libregraph.Group, 0, len(userGroups))
|
||||
for _, g := range userGroups {
|
||||
groups = append(groups, *i.createGroupModelFromLDAP(g))
|
||||
}
|
||||
u.MemberOf = groups
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (i *LDAP) GetUsers(ctx context.Context, queryParam url.Values) ([]*libregraph.User, error) {
|
||||
i.logger.Debug().Str("backend", "ldap").Msg("GetUsers")
|
||||
|
||||
search := queryParam.Get("search")
|
||||
if search == "" {
|
||||
search = queryParam.Get("$search")
|
||||
}
|
||||
userFilter := fmt.Sprintf("%s(objectClass=%s)", i.userFilter, i.userObjectClass)
|
||||
if search != "" {
|
||||
search = ldap.EscapeFilter(search)
|
||||
userFilter = fmt.Sprintf(
|
||||
"(&(%s)(|(%s=%s*)(%s=%s*)(%s=%s*)))",
|
||||
userFilter,
|
||||
i.userAttributeMap.userName, search,
|
||||
i.userAttributeMap.mail, search,
|
||||
i.userAttributeMap.displayName, search,
|
||||
)
|
||||
}
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
i.userBaseDN, i.userScope, ldap.NeverDerefAliases, 0, 0, false,
|
||||
userFilter,
|
||||
[]string{
|
||||
i.userAttributeMap.displayName,
|
||||
i.userAttributeMap.id,
|
||||
i.userAttributeMap.mail,
|
||||
i.userAttributeMap.userName,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
i.logger.Debug().Str("backend", "ldap").Msgf("Search %s", i.userBaseDN)
|
||||
res, err := i.conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
|
||||
}
|
||||
|
||||
users := make([]*libregraph.User, 0, len(res.Entries))
|
||||
|
||||
for _, e := range res.Entries {
|
||||
sel := strings.Split(queryParam.Get("$select"), ",")
|
||||
exp := strings.Split(queryParam.Get("$expand"), ",")
|
||||
u := i.createUserModelFromLDAP(e)
|
||||
if slices.Contains(sel, "memberOf") || slices.Contains(exp, "memberOf") {
|
||||
userGroups, err := i.getGroupsForUser(e.DN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(userGroups) > 0 {
|
||||
expand := ldap.EscapeFilter(queryParam.Get("expand"))
|
||||
if expand == "" {
|
||||
expand = "false"
|
||||
}
|
||||
groups := make([]libregraph.Group, 0, len(userGroups))
|
||||
for _, g := range userGroups {
|
||||
groups = append(groups, *i.createGroupModelFromLDAP(g))
|
||||
}
|
||||
u.MemberOf = groups
|
||||
}
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (i *LDAP) getGroupsForUser(dn string) ([]*ldap.Entry, error) {
|
||||
groupFilter := fmt.Sprintf(
|
||||
"(%s=%s)",
|
||||
i.groupAttributeMap.member, dn,
|
||||
)
|
||||
userGroups, err := i.getLDAPGroupsByFilter(groupFilter, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return userGroups, nil
|
||||
}
|
||||
|
||||
func (i *LDAP) GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error) {
|
||||
i.logger.Debug().Str("backend", "ldap").Msg("GetGroup")
|
||||
e, err := i.getLDAPGroupByNameOrID(nameOrID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sel := strings.Split(queryParam.Get("$select"), ",")
|
||||
exp := strings.Split(queryParam.Get("$expand"), ",")
|
||||
g := i.createGroupModelFromLDAP(e)
|
||||
if slices.Contains(sel, "members") || slices.Contains(exp, "members") {
|
||||
members, err := i.GetGroupMembers(ctx, *g.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(members) > 0 {
|
||||
m := make([]libregraph.User, 0, len(members))
|
||||
for _, u := range members {
|
||||
m = append(m, *u)
|
||||
}
|
||||
g.Members = m
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (i *LDAP) getLDAPGroupByID(id string, requestMembers bool) (*ldap.Entry, error) {
|
||||
id = ldap.EscapeFilter(id)
|
||||
filter := fmt.Sprintf("(%s=%s)", i.groupAttributeMap.id, id)
|
||||
return i.getLDAPGroupByFilter(filter, requestMembers)
|
||||
}
|
||||
|
||||
func (i *LDAP) getLDAPGroupByNameOrID(nameOrID string, requestMembers bool) (*ldap.Entry, error) {
|
||||
nameOrID = ldap.EscapeFilter(nameOrID)
|
||||
filter := fmt.Sprintf("(|(%s=%s)(%s=%s))", i.groupAttributeMap.name, nameOrID, i.groupAttributeMap.id, nameOrID)
|
||||
return i.getLDAPGroupByFilter(filter, requestMembers)
|
||||
}
|
||||
|
||||
func (i *LDAP) getLDAPGroupByFilter(filter string, requestMembers bool) (*ldap.Entry, error) {
|
||||
e, err := i.getLDAPGroupsByFilter(filter, requestMembers, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(e) == 0 {
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, "not found")
|
||||
}
|
||||
|
||||
return e[0], nil
|
||||
}
|
||||
|
||||
// Search for LDAP Groups matching the specified filter, if requestMembers is true the groupMemberShip
|
||||
// attribute will be part of the result attributes. The LDAP filter is combined with the configured groupFilter
|
||||
// resulting in a filter like "(&(LDAP.groupFilter)(objectClass=LDAP.groupObjectClass)(<filter_from_args>))"
|
||||
func (i *LDAP) getLDAPGroupsByFilter(filter string, requestMembers, single bool) ([]*ldap.Entry, error) {
|
||||
attrs := []string{
|
||||
i.groupAttributeMap.name,
|
||||
i.groupAttributeMap.id,
|
||||
}
|
||||
|
||||
if requestMembers {
|
||||
attrs = append(attrs, i.groupAttributeMap.member)
|
||||
}
|
||||
|
||||
sizelimit := 0
|
||||
if single {
|
||||
sizelimit = 1
|
||||
}
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, sizelimit, 0, false,
|
||||
fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.groupObjectClass, filter),
|
||||
attrs,
|
||||
nil,
|
||||
)
|
||||
i.logger.Debug().Str("backend", "ldap").Msgf("Search %s", i.groupBaseDN)
|
||||
res, err := i.conn.Search(searchRequest)
|
||||
|
||||
if err != nil {
|
||||
var errmsg string
|
||||
if lerr, ok := err.(*ldap.Error); ok {
|
||||
if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
|
||||
errmsg = fmt.Sprintf("too many results searching for group '%s'", filter)
|
||||
i.logger.Debug().Str("backend", "ldap").Err(lerr).Msg(errmsg)
|
||||
}
|
||||
}
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
|
||||
}
|
||||
return res.Entries, nil
|
||||
}
|
||||
|
||||
// removeMemberFromGroupEntry creates an LDAP Modify request (not sending it)
|
||||
// that would update the supplied entry to remove the specified member from the
|
||||
// group
|
||||
func (i *LDAP) removeMemberFromGroupEntry(group *ldap.Entry, memberDN string) (*ldap.ModifyRequest, error) {
|
||||
nOldMemberDN, err := ldapdn.ParseNormalize(memberDN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members := group.GetEqualFoldAttributeValues(i.groupAttributeMap.member)
|
||||
found := false
|
||||
for _, member := range members {
|
||||
if member == "" {
|
||||
continue
|
||||
}
|
||||
if nMember, err := ldapdn.ParseNormalize(member); err != nil {
|
||||
// We couldn't parse the member value as a DN. Let's keep it
|
||||
// as it is but log a warning
|
||||
i.logger.Warn().Str("memberDN", member).Err(err).Msg("Couldn't parse DN")
|
||||
continue
|
||||
} else {
|
||||
if nMember == nOldMemberDN {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
i.logger.Debug().Str("backend", "ldap").Str("groupdn", group.DN).Str("member", memberDN).
|
||||
Msg("The target is not a member of the group")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mr := ldap.ModifyRequest{DN: group.DN}
|
||||
if len(members) == 1 {
|
||||
mr.Add(i.groupAttributeMap.member, []string{""})
|
||||
}
|
||||
mr.Delete(i.groupAttributeMap.member, []string{memberDN})
|
||||
return &mr, nil
|
||||
}
|
||||
|
||||
func (i *LDAP) GetGroups(ctx context.Context, queryParam url.Values) ([]*libregraph.Group, error) {
|
||||
i.logger.Debug().Str("backend", "ldap").Msg("GetGroups")
|
||||
|
||||
search := queryParam.Get("search")
|
||||
if search == "" {
|
||||
search = queryParam.Get("$search")
|
||||
}
|
||||
groupFilter := fmt.Sprintf("%s(objectClass=%s)", i.groupFilter, i.groupObjectClass)
|
||||
if search != "" {
|
||||
search = ldap.EscapeFilter(search)
|
||||
groupFilter = fmt.Sprintf(
|
||||
"(&(%s)(|(%s=%s*)(%s=%s*)))",
|
||||
groupFilter,
|
||||
i.groupAttributeMap.name, search,
|
||||
i.groupAttributeMap.id, search,
|
||||
)
|
||||
}
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 0, 0, false,
|
||||
groupFilter,
|
||||
[]string{
|
||||
i.groupAttributeMap.name,
|
||||
i.groupAttributeMap.id,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
i.logger.Debug().Str("backend", "ldap").Str("Base", i.groupBaseDN).Str("filter", groupFilter).Msg("ldap search")
|
||||
res, err := i.conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
|
||||
}
|
||||
|
||||
groups := make([]*libregraph.Group, 0, len(res.Entries))
|
||||
|
||||
for _, e := range res.Entries {
|
||||
sel := strings.Split(queryParam.Get("$select"), ",")
|
||||
exp := strings.Split(queryParam.Get("$expand"), ",")
|
||||
g := i.createGroupModelFromLDAP(e)
|
||||
if slices.Contains(sel, "members") || slices.Contains(exp, "members") {
|
||||
members, err := i.GetGroupMembers(ctx, *g.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(members) > 0 {
|
||||
m := make([]libregraph.User, 0, len(members))
|
||||
for _, u := range members {
|
||||
m = append(m, *u)
|
||||
}
|
||||
g.Members = m
|
||||
}
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetGroupMembers implements the Backend Interface for the LDAP Backend
|
||||
func (i *LDAP) GetGroupMembers(ctx context.Context, groupID string) ([]*libregraph.User, error) {
|
||||
e, err := i.getLDAPGroupByNameOrID(groupID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := []*libregraph.User{}
|
||||
|
||||
for _, memberDN := range e.GetEqualFoldAttributeValues(i.groupAttributeMap.member) {
|
||||
if memberDN == "" {
|
||||
continue
|
||||
}
|
||||
i.logger.Debug().Str("memberDN", memberDN).Msg("lookup")
|
||||
ue, err := i.getUserByDN(memberDN)
|
||||
if err != nil {
|
||||
// Ignore errors when reading a specific member fails, just log them and continue
|
||||
i.logger.Warn().Err(err).Str("member", memberDN).Msg("error reading group member")
|
||||
continue
|
||||
}
|
||||
result = append(result, i.createUserModelFromLDAP(ue))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CreateGroup implements the Backend Interface for the LDAP Backend
|
||||
// It is currently restricted to managing groups based on the "groupOfNames" ObjectClass.
|
||||
// As "groupOfNames" requires a "member" Attribute to be present. Empty Groups (groups
|
||||
// without a member) a represented by adding an empty DN as the single member.
|
||||
func (i *LDAP) CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error) {
|
||||
if !i.writeEnabled {
|
||||
return nil, errorcode.New(errorcode.NotAllowed, "server is configured read-only")
|
||||
}
|
||||
ar := ldap.AddRequest{
|
||||
DN: fmt.Sprintf("cn=%s,%s", *group.DisplayName, i.groupBaseDN),
|
||||
Attributes: []ldap.Attribute{
|
||||
{
|
||||
Type: i.groupAttributeMap.name,
|
||||
Vals: []string{*group.DisplayName},
|
||||
},
|
||||
// This is a crutch to allow groups without members for LDAP Server's which
|
||||
// that apply strict Schema checking. The RFCs define "member/uniqueMember"
|
||||
// as required attribute for groupOfNames/groupOfUniqueNames. So we
|
||||
// add an empty string (which is a valid DN) as the initial member.
|
||||
// It will be replace once real members are added.
|
||||
// We might wanna use the newer, but not so broadly used "groupOfMembers"
|
||||
// objectclass (RFC2307bis-02) where "member" is optional.
|
||||
{
|
||||
Type: i.groupAttributeMap.member,
|
||||
Vals: []string{""},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// TODO make group objectclass configurable to support e.g. posixGroup, groupOfUniqueNames, groupOfMembers?}
|
||||
objectClasses := []string{"groupOfNames", "top"}
|
||||
|
||||
if !i.useServerUUID {
|
||||
ar.Attribute("owncloudUUID", []string{uuid.Must(uuid.NewV4()).String()})
|
||||
objectClasses = append(objectClasses, "owncloud")
|
||||
}
|
||||
ar.Attribute("objectClass", objectClasses)
|
||||
|
||||
if err := i.conn.Add(&ar); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read back group from LDAP to get the generated UUID
|
||||
e, err := i.getGroupByDN(ar.DN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return i.createGroupModelFromLDAP(e), nil
|
||||
}
|
||||
|
||||
// DeleteGroup implements the Backend Interface.
|
||||
func (i *LDAP) DeleteGroup(ctx context.Context, id string) error {
|
||||
if !i.writeEnabled {
|
||||
return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
|
||||
}
|
||||
e, err := i.getLDAPGroupByID(id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dr := ldap.DelRequest{DN: e.DN}
|
||||
if err = i.conn.Del(&dr); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMembersToGroup implements the Backend Interface for the LDAP backend.
|
||||
// Currently it is limited to adding Users as Group members. Adding other groups
|
||||
// as members is not yet implemented
|
||||
func (i *LDAP) AddMembersToGroup(ctx context.Context, groupID string, memberIDs []string) error {
|
||||
ge, err := i.getLDAPGroupByID(groupID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mr := ldap.ModifyRequest{DN: ge.DN}
|
||||
// Handle empty groups (using the empty member attribute)
|
||||
current := ge.GetEqualFoldAttributeValues(i.groupAttributeMap.member)
|
||||
if len(current) == 1 && current[0] == "" {
|
||||
mr.Delete(i.groupAttributeMap.member, []string{""})
|
||||
}
|
||||
|
||||
// Create a Set of current members for faster lookups
|
||||
currentSet := make(map[string]struct{}, len(current))
|
||||
for _, currentMember := range current {
|
||||
// We can ignore any empty member value here
|
||||
if currentMember == "" {
|
||||
continue
|
||||
}
|
||||
nCurrentMember, err := ldapdn.ParseNormalize(currentMember)
|
||||
if err != nil {
|
||||
// We couldn't parse the member value as a DN. Let's skip it, but log a warning
|
||||
i.logger.Warn().Str("memberDN", currentMember).Err(err).Msg("Couldn't parse DN")
|
||||
continue
|
||||
}
|
||||
currentSet[nCurrentMember] = struct{}{}
|
||||
}
|
||||
|
||||
var newMemberDNs []string
|
||||
for _, memberID := range memberIDs {
|
||||
me, err := i.getLDAPUserByID(memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nDN, err := ldapdn.ParseNormalize(me.DN)
|
||||
if err != nil {
|
||||
i.logger.Error().Str("new member", me.DN).Err(err).Msg("Couldn't parse DN")
|
||||
return err
|
||||
}
|
||||
if _, present := currentSet[nDN]; !present {
|
||||
newMemberDNs = append(newMemberDNs, me.DN)
|
||||
} else {
|
||||
i.logger.Debug().Str("memberDN", me.DN).Msg("Member already present in group. Skipping")
|
||||
}
|
||||
}
|
||||
|
||||
if len(newMemberDNs) > 0 {
|
||||
mr.Add(i.groupAttributeMap.member, newMemberDNs)
|
||||
|
||||
if err := i.conn.Modify(&mr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveMemberFromGroup implements the Backend Interface.
|
||||
func (i *LDAP) RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error {
|
||||
ge, err := i.getLDAPGroupByID(groupID, true)
|
||||
if err != nil {
|
||||
i.logger.Warn().Str("backend", "ldap").Str("groupID", groupID).Msg("Error looking up group")
|
||||
return err
|
||||
}
|
||||
me, err := i.getLDAPUserByID(memberID)
|
||||
if err != nil {
|
||||
i.logger.Warn().Str("backend", "ldap").Str("memberID", memberID).Msg("Error looking up group member")
|
||||
return err
|
||||
}
|
||||
i.logger.Debug().Str("backend", "ldap").Str("groupdn", ge.DN).Str("member", me.DN).Msg("remove member")
|
||||
|
||||
if mr, err := i.removeMemberFromGroupEntry(ge, me.DN); err == nil && mr != nil {
|
||||
return i.conn.Modify(mr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *LDAP) createUserModelFromLDAP(e *ldap.Entry) *libregraph.User {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return &libregraph.User{
|
||||
DisplayName: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.displayName)),
|
||||
Mail: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.mail)),
|
||||
OnPremisesSamAccountName: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.userName)),
|
||||
Id: pointerOrNil(e.GetEqualFoldAttributeValue(i.userAttributeMap.id)),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *LDAP) createGroupModelFromLDAP(e *ldap.Entry) *libregraph.Group {
|
||||
return &libregraph.Group{
|
||||
DisplayName: pointerOrNil(e.GetEqualFoldAttributeValue(i.groupAttributeMap.name)),
|
||||
Id: pointerOrNil(e.GetEqualFoldAttributeValue(i.groupAttributeMap.id)),
|
||||
}
|
||||
}
|
||||
|
||||
func pointerOrNil(val string) *string {
|
||||
if val == "" {
|
||||
return nil
|
||||
}
|
||||
return &val
|
||||
}
|
||||
|
||||
func stringToScope(scope string) (int, error) {
|
||||
var s int
|
||||
switch scope {
|
||||
case "sub":
|
||||
s = ldap.ScopeWholeSubtree
|
||||
case "one":
|
||||
s = ldap.ScopeSingleLevel
|
||||
case "base":
|
||||
s = ldap.ScopeBaseObject
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid Scope '%s'", scope)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package ldap
|
||||
|
||||
// LDAP automatic reconnection mechanism, inspired by:
|
||||
// https://gist.github.com/emsearcy/cba3295d1a06d4c432ab4f6173b65e4f#file-ldap_snippet-go
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
)
|
||||
|
||||
var (
|
||||
errMaxRetries = errors.New("max retries")
|
||||
)
|
||||
|
||||
type ldapConnection struct {
|
||||
Conn *ldap.Conn
|
||||
Error error
|
||||
}
|
||||
|
||||
// Implements the ldap.CLient interface
|
||||
type ConnWithReconnect struct {
|
||||
conn chan ldapConnection
|
||||
reset chan *ldap.Conn
|
||||
retries int
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
URI string
|
||||
BindDN string
|
||||
BindPassword string
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
func NewLDAPWithReconnect(logger *log.Logger, config Config) ConnWithReconnect {
|
||||
conn := ConnWithReconnect{
|
||||
conn: make(chan ldapConnection),
|
||||
reset: make(chan *ldap.Conn),
|
||||
retries: 1,
|
||||
logger: logger,
|
||||
}
|
||||
go conn.ldapAutoConnect(config)
|
||||
return conn
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) Search(sr *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
conn, err := c.GetConnection()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var res *ldap.SearchResult
|
||||
for try := 0; try <= c.retries; try++ {
|
||||
res, err = conn.Search(sr)
|
||||
if !ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
|
||||
// non network error, return it to the client
|
||||
return res, err
|
||||
}
|
||||
|
||||
c.logger.Debug().Msgf("Network Error. attempt %d", try)
|
||||
conn, err = c.reconnect(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.logger.Debug().Msg("retrying LDAP Search")
|
||||
}
|
||||
// if we get here we reached the maximum retries. So return an error
|
||||
return nil, ldap.NewError(ldap.ErrorNetwork, errMaxRetries)
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) Add(a *ldap.AddRequest) error {
|
||||
conn, err := c.GetConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for try := 0; try <= c.retries; try++ {
|
||||
err = conn.Add(a)
|
||||
if !ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
|
||||
// non network error, return it to the client
|
||||
return err
|
||||
}
|
||||
|
||||
c.logger.Debug().Msgf("Network Error. attempt %d", try)
|
||||
conn, err = c.reconnect(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.logger.Debug().Msg("retrying LDAP Add")
|
||||
}
|
||||
// if we get here we reached the maximum retries. So return an error
|
||||
return ldap.NewError(ldap.ErrorNetwork, errMaxRetries)
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) Del(d *ldap.DelRequest) error {
|
||||
conn, err := c.GetConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for try := 0; try <= c.retries; try++ {
|
||||
err = conn.Del(d)
|
||||
if !ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
|
||||
// non network error, return it to the client
|
||||
return err
|
||||
}
|
||||
|
||||
c.logger.Debug().Msgf("Network Error. attempt %d", try)
|
||||
conn, err = c.reconnect(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.logger.Debug().Msg("retrying LDAP Del")
|
||||
}
|
||||
// if we get here we reached the maximum retries. So return an error
|
||||
return ldap.NewError(ldap.ErrorNetwork, errMaxRetries)
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) Modify(m *ldap.ModifyRequest) error {
|
||||
conn, err := c.GetConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for try := 0; try <= c.retries; try++ {
|
||||
err = conn.Modify(m)
|
||||
if !ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
|
||||
// non network error, return it to the client
|
||||
return err
|
||||
}
|
||||
|
||||
c.logger.Debug().Msgf("Network Error. attempt %d", try)
|
||||
conn, err = c.reconnect(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.logger.Debug().Msg("retrying LDAP Modify")
|
||||
}
|
||||
// if we get here we reached the maximum retries. So return an error
|
||||
return ldap.NewError(ldap.ErrorNetwork, errMaxRetries)
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) ModifyDN(m *ldap.ModifyDNRequest) error {
|
||||
conn, err := c.GetConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for try := 0; try <= c.retries; try++ {
|
||||
err = conn.ModifyDN(m)
|
||||
if !ldap.IsErrorWithCode(err, ldap.ErrorNetwork) {
|
||||
// non network error, return it to the client
|
||||
return err
|
||||
}
|
||||
|
||||
c.logger.Debug().Msgf("Network Error. attempt %d", try)
|
||||
conn, err = c.reconnect(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.logger.Debug().Msg("retrying LDAP ModifyDN")
|
||||
}
|
||||
// if we get here we reached the maximum retries. So return an error
|
||||
return ldap.NewError(ldap.ErrorNetwork, errMaxRetries)
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) GetConnection() (*ldap.Conn, error) {
|
||||
conn := <-c.conn
|
||||
if conn.Conn != nil && !ldap.IsErrorWithCode(conn.Error, ldap.ErrorNetwork) {
|
||||
c.logger.Debug().Msg("using existing Connection")
|
||||
return conn.Conn, conn.Error
|
||||
}
|
||||
return c.reconnect(conn.Conn)
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) ldapAutoConnect(config Config) {
|
||||
l, err := c.ldapConnect(config)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("autoconnect could not get ldap Connection")
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case resConn := <-c.reset:
|
||||
// Only close the connection and reconnect if the current
|
||||
// connection, matches the one we got via the reset channel.
|
||||
// If they differ we already reconnected
|
||||
if l != nil && l == resConn {
|
||||
c.logger.Debug().Msgf("closing connection %v", &l)
|
||||
l.Close()
|
||||
}
|
||||
if l == resConn || l == nil {
|
||||
c.logger.Debug().Msg("reconnecting to LDAP")
|
||||
l, err = c.ldapConnect(config)
|
||||
} else {
|
||||
c.logger.Debug().Msg("already reconnected")
|
||||
}
|
||||
case c.conn <- ldapConnection{l, err}:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) ldapConnect(config Config) (*ldap.Conn, error) {
|
||||
c.logger.Debug().Msgf("Connecting to %s", config.URI)
|
||||
|
||||
var err error
|
||||
var l *ldap.Conn
|
||||
if config.TLSConfig != nil {
|
||||
l, err = ldap.DialURL(config.URI, ldap.DialWithTLSConfig(config.TLSConfig))
|
||||
} else {
|
||||
l, err = ldap.DialURL(config.URI)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("could not get ldap Connection")
|
||||
} else {
|
||||
c.logger.Debug().Msg("LDAP Connected")
|
||||
if config.BindDN != "" {
|
||||
c.logger.Debug().Msgf("Binding as %s", config.BindDN)
|
||||
err = l.Bind(config.BindDN, config.BindPassword)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Bind failed")
|
||||
l.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return l, err
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) reconnect(resetConn *ldap.Conn) (*ldap.Conn, error) {
|
||||
c.logger.Debug().Msg("LDAP connection reset")
|
||||
c.reset <- resetConn
|
||||
c.logger.Debug().Msg("Waiting for new connection")
|
||||
result := <-c.conn
|
||||
return result.Conn, result.Error
|
||||
}
|
||||
|
||||
// Remaining methods to fulfill ldap.Client interface
|
||||
|
||||
func (c ConnWithReconnect) Start() {}
|
||||
|
||||
func (c ConnWithReconnect) StartTLS(*tls.Config) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) Close() {}
|
||||
|
||||
func (c ConnWithReconnect) IsClosing() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) SetTimeout(time.Duration) {}
|
||||
|
||||
func (c ConnWithReconnect) Bind(username, password string) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) UnauthenticatedBind(username string) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) SimpleBind(*ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) ExternalBind() error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) ModifyWithResult(m *ldap.ModifyRequest) (*ldap.ModifyResult, error) {
|
||||
conn, err := c.GetConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn.ModifyWithResult(m)
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) Compare(dn, attribute, value string) (bool, error) {
|
||||
return false, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) PasswordModify(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ConnWithReconnect) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
)
|
||||
|
||||
// ldapMock implements the ldap.Client interfac
|
||||
type ldapMock struct {
|
||||
SearchFunc *searchFunc
|
||||
}
|
||||
|
||||
type searchFunc func(*ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||
|
||||
func getMockedBackend(sf *searchFunc, lc config.LDAP, logger *log.Logger) (*LDAP, error) {
|
||||
// Mock a Sizelimit Error
|
||||
lm := ldapMock{SearchFunc: sf}
|
||||
return NewLDAPBackend(lm, lconfig, logger)
|
||||
}
|
||||
|
||||
var lconfig = config.LDAP{
|
||||
UserBaseDN: "dc=test",
|
||||
UserSearchScope: "sub",
|
||||
UserFilter: "filter",
|
||||
UserDisplayNameAttribute: "displayname",
|
||||
UserIDAttribute: "entryUUID",
|
||||
UserEmailAttribute: "mail",
|
||||
UserNameAttribute: "uid",
|
||||
|
||||
GroupBaseDN: "dc=test",
|
||||
GroupSearchScope: "sub",
|
||||
GroupFilter: "filter",
|
||||
GroupNameAttribute: "cn",
|
||||
GroupIDAttribute: "entryUUID",
|
||||
}
|
||||
|
||||
var userEntry = ldap.NewEntry("uid=user",
|
||||
map[string][]string{
|
||||
"uid": {"user"},
|
||||
"displayname": {"DisplayName"},
|
||||
"mail": {"user@example"},
|
||||
"entryuuid": {"abcd-defg"},
|
||||
})
|
||||
var groupEntry = ldap.NewEntry("cn=group",
|
||||
map[string][]string{
|
||||
"cn": {"group"},
|
||||
"entryuuid": {"abcd-defg"},
|
||||
})
|
||||
|
||||
var logger = log.NewLogger(log.Level("debug"))
|
||||
|
||||
func TestNewLDAPBackend(t *testing.T) {
|
||||
|
||||
l := ldapMock{}
|
||||
|
||||
tc := lconfig
|
||||
tc.UserDisplayNameAttribute = ""
|
||||
if _, err := NewLDAPBackend(l, tc, &logger); err == nil {
|
||||
t.Error("Should fail with incomplete user attr config")
|
||||
}
|
||||
|
||||
tc = lconfig
|
||||
tc.GroupIDAttribute = ""
|
||||
if _, err := NewLDAPBackend(l, tc, &logger); err == nil {
|
||||
t.Errorf("Should fail with incomplete group config")
|
||||
}
|
||||
|
||||
tc = lconfig
|
||||
tc.UserSearchScope = ""
|
||||
if _, err := NewLDAPBackend(l, tc, &logger); err == nil {
|
||||
t.Errorf("Should fail with invalid user search scope")
|
||||
}
|
||||
|
||||
tc = lconfig
|
||||
tc.GroupSearchScope = ""
|
||||
if _, err := NewLDAPBackend(l, tc, &logger); err == nil {
|
||||
t.Errorf("Should fail with invalid group search scope")
|
||||
}
|
||||
|
||||
if _, err := NewLDAPBackend(l, lconfig, &logger); err != nil {
|
||||
t.Errorf("Should fail with invalid group search scope")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestCreateUserModelFromLDAP(t *testing.T) {
|
||||
l := ldapMock{}
|
||||
logger := log.NewLogger(log.Level("debug"))
|
||||
|
||||
b, _ := NewLDAPBackend(l, lconfig, &logger)
|
||||
if user := b.createUserModelFromLDAP(nil); user != nil {
|
||||
t.Errorf("createUserModelFromLDAP should return on nil Entry")
|
||||
}
|
||||
user := b.createUserModelFromLDAP(userEntry)
|
||||
if user == nil {
|
||||
t.Error("Converting a valid LDAP Entry should succeed")
|
||||
} else {
|
||||
if *user.OnPremisesSamAccountName != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.userName) {
|
||||
t.Errorf("Error creating msGraph User from LDAP Entry: %v != %v", user.OnPremisesSamAccountName, pointerOrNil(userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.userName)))
|
||||
}
|
||||
if *user.Mail != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.mail) {
|
||||
t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.Mail, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.mail))
|
||||
}
|
||||
if *user.DisplayName != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.displayName) {
|
||||
t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.DisplayName, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.displayName))
|
||||
}
|
||||
if *user.Id != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id) {
|
||||
t.Errorf("Error creating msGraph User from LDAP Entry: %s != %s", *user.Id, userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUser(t *testing.T) {
|
||||
// Mock a Sizelimit Error
|
||||
var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock"))
|
||||
}
|
||||
b, _ := getMockedBackend(&sf, lconfig, &logger)
|
||||
|
||||
queryParamExpand := url.Values{
|
||||
"$expand": []string{"memberOf"},
|
||||
}
|
||||
queryParamSelect := url.Values{
|
||||
"$select": []string{"memberOf"},
|
||||
}
|
||||
_, err := b.GetUser(context.Background(), "fred", nil)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
_, err = b.GetUser(context.Background(), "fred", queryParamExpand)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
_, err = b.GetUser(context.Background(), "fred", queryParamSelect)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
// Mock an empty Search Result
|
||||
sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{}, nil
|
||||
}
|
||||
b, _ = getMockedBackend(&sf, lconfig, &logger)
|
||||
_, err = b.GetUser(context.Background(), "fred", nil)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
_, err = b.GetUser(context.Background(), "fred", queryParamExpand)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
_, err = b.GetUser(context.Background(), "fred", queryParamSelect)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
// Mock a valid Search Result
|
||||
sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{
|
||||
Entries: []*ldap.Entry{userEntry},
|
||||
}, nil
|
||||
}
|
||||
b, _ = getMockedBackend(&sf, lconfig, &logger)
|
||||
u, err := b.GetUser(context.Background(), "user", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Expected GetUser to succeed. Got %s", err.Error())
|
||||
} else if *u.Id != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id) {
|
||||
t.Errorf("Expected GetUser to return a valid user")
|
||||
}
|
||||
|
||||
u, err = b.GetUser(context.Background(), "user", queryParamExpand)
|
||||
if err != nil {
|
||||
t.Errorf("Expected GetUser to succeed. Got %s", err.Error())
|
||||
} else if *u.Id != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id) {
|
||||
t.Errorf("Expected GetUser to return a valid user")
|
||||
}
|
||||
|
||||
u, err = b.GetUser(context.Background(), "user", queryParamSelect)
|
||||
if err != nil {
|
||||
t.Errorf("Expected GetUser to succeed. Got %s", err.Error())
|
||||
} else if *u.Id != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id) {
|
||||
t.Errorf("Expected GetUser to return a valid user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUsers(t *testing.T) {
|
||||
var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock"))
|
||||
}
|
||||
b, _ := getMockedBackend(&sf, lconfig, &logger)
|
||||
_, err := b.GetUsers(context.Background(), url.Values{})
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{}, nil
|
||||
}
|
||||
b, _ = getMockedBackend(&sf, lconfig, &logger)
|
||||
g, err := b.GetUsers(context.Background(), url.Values{})
|
||||
if err != nil {
|
||||
t.Errorf("Expected success, got '%s'", err.Error())
|
||||
} else if g == nil || len(g) != 0 {
|
||||
t.Errorf("Expected zero length user slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGroup(t *testing.T) {
|
||||
// Mock a Sizelimit Error
|
||||
var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock"))
|
||||
}
|
||||
queryParamExpand := url.Values{
|
||||
"$expand": []string{"memberOf"},
|
||||
}
|
||||
queryParamSelect := url.Values{
|
||||
"$select": []string{"memberOf"},
|
||||
}
|
||||
b, _ := getMockedBackend(&sf, lconfig, &logger)
|
||||
_, err := b.GetGroup(context.Background(), "group", nil)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
// Mock an empty Search Result
|
||||
sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{}, nil
|
||||
}
|
||||
b, _ = getMockedBackend(&sf, lconfig, &logger)
|
||||
_, err = b.GetGroup(context.Background(), "group", nil)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
// Mock a valid Search Result
|
||||
sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{
|
||||
Entries: []*ldap.Entry{groupEntry},
|
||||
}, nil
|
||||
}
|
||||
b, _ = getMockedBackend(&sf, lconfig, &logger)
|
||||
g, err := b.GetGroup(context.Background(), "group", nil)
|
||||
if err != nil {
|
||||
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
|
||||
} else if *g.Id != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
|
||||
t.Errorf("Expected GetGroup to return a valid group")
|
||||
}
|
||||
g, err = b.GetGroup(context.Background(), "group", queryParamExpand)
|
||||
if err != nil {
|
||||
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
|
||||
} else if *g.Id != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
|
||||
t.Errorf("Expected GetGroup to return a valid group")
|
||||
}
|
||||
g, err = b.GetGroup(context.Background(), "group", queryParamSelect)
|
||||
if err != nil {
|
||||
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
|
||||
} else if *g.Id != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
|
||||
t.Errorf("Expected GetGroup to return a valid group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGroups(t *testing.T) {
|
||||
var sf searchFunc = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock"))
|
||||
}
|
||||
b, _ := getMockedBackend(&sf, lconfig, &logger)
|
||||
_, err := b.GetGroups(context.Background(), url.Values{})
|
||||
if err == nil || err.Error() != "itemNotFound" {
|
||||
t.Errorf("Expected 'itemNotFound' got '%s'", err.Error())
|
||||
}
|
||||
|
||||
sf = func(*ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
return &ldap.SearchResult{}, nil
|
||||
}
|
||||
b, _ = getMockedBackend(&sf, lconfig, &logger)
|
||||
g, err := b.GetGroups(context.Background(), url.Values{})
|
||||
if err != nil {
|
||||
t.Errorf("Expected success, got '%s'", err.Error())
|
||||
} else if g == nil || len(g) != 0 {
|
||||
t.Errorf("Expected zero length user slice")
|
||||
}
|
||||
}
|
||||
|
||||
// below here ldap.Client interface method for ldapMock
|
||||
|
||||
func (c ldapMock) Start() {}
|
||||
|
||||
func (c ldapMock) StartTLS(*tls.Config) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) Close() {}
|
||||
|
||||
func (c ldapMock) IsClosing() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c ldapMock) SetTimeout(time.Duration) {}
|
||||
|
||||
func (c ldapMock) Bind(username, password string) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) UnauthenticatedBind(username string) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) SimpleBind(*ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) ExternalBind() error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) Add(*ldap.AddRequest) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) Del(*ldap.DelRequest) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) Modify(*ldap.ModifyRequest) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) ModifyDN(*ldap.ModifyDNRequest) error {
|
||||
return ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) ModifyWithResult(*ldap.ModifyRequest) (*ldap.ModifyResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) Compare(dn, attribute, value string) (bool, error) {
|
||||
return false, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) PasswordModify(*ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
|
||||
func (c ldapMock) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
if c.SearchFunc != nil {
|
||||
return (*c.SearchFunc)(searchRequest)
|
||||
}
|
||||
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
func (c ldapMock) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error) {
|
||||
return nil, ldap.NewError(ldap.LDAPResultNotSupported, fmt.Errorf("not implemented"))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
)
|
||||
|
||||
// LoggerFromConfig initializes a service-specific logger instance.
|
||||
func Configure(name string, cfg *config.Log) log.Logger {
|
||||
return log.NewLogger(
|
||||
log.Name(name),
|
||||
log.Level(cfg.Level),
|
||||
log.Pretty(cfg.Pretty),
|
||||
log.Color(cfg.Color),
|
||||
log.File(cfg.File),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package metrics
|
||||
|
||||
import "github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
var (
|
||||
// Namespace defines the namespace for the defines metrics.
|
||||
Namespace = "ocis"
|
||||
|
||||
// Subsystem defines the subsystem for the defines metrics.
|
||||
Subsystem = "graph"
|
||||
)
|
||||
|
||||
// Metrics defines the available metrics of this service.
|
||||
type Metrics struct {
|
||||
// Counter *prometheus.CounterVec
|
||||
BuildInfo *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
// New initializes the available metrics.
|
||||
func New() *Metrics {
|
||||
m := &Metrics{
|
||||
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: Namespace,
|
||||
Subsystem: Subsystem,
|
||||
Name: "build_info",
|
||||
Help: "Build information",
|
||||
}, []string{"version"}),
|
||||
}
|
||||
|
||||
_ = prometheus.Register(m.BuildInfo)
|
||||
// TODO: implement metrics
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cs3org/reva/v2/pkg/auth/scope"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/token/manager/jwt"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/account"
|
||||
opkgm "github.com/owncloud/ocis/v2/ocis-pkg/middleware"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
gmmetadata "go-micro.dev/v4/metadata"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// authOptions initializes the available default options.
|
||||
func authOptions(opts ...account.Option) account.Options {
|
||||
opt := account.Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Auth provides a middleware to authenticate requests using the x-access-token header value
|
||||
// and write it to the context. If there is no x-access-token the middleware prevents access and renders a json document.
|
||||
func Auth(opts ...account.Option) func(http.Handler) http.Handler {
|
||||
// Note: This largely duplicates what ocis-pkg/middleware/account.go already does (apart from a slightly different error
|
||||
// handling). Ideally we should merge both middlewares.
|
||||
opt := authOptions(opts...)
|
||||
tokenManager, err := jwt.New(map[string]interface{}{
|
||||
"secret": opt.JWTSecret,
|
||||
"expires": int64(24 * 60 * 60),
|
||||
})
|
||||
if err != nil {
|
||||
opt.Logger.Fatal().Err(err).Msgf("Could not initialize token-manager")
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
t := r.Header.Get("x-access-token")
|
||||
if t == "" {
|
||||
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "Access token is empty.")
|
||||
/* msgraph error for GET https://graph.microsoft.com/v1.0/me
|
||||
{
|
||||
"error":
|
||||
{
|
||||
"code":"InvalidAuthenticationToken",
|
||||
"message":"Access token is empty.",
|
||||
"innerError":{
|
||||
"date":"2021-07-09T14:40:51",
|
||||
"request-id":"bb12f7db-b4c4-43a9-ba4b-31676aeed019",
|
||||
"client-request-id":"bb12f7db-b4c4-43a9-ba4b-31676aeed019"
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
return
|
||||
}
|
||||
|
||||
u, tokenScope, err := tokenManager.DismantleToken(r.Context(), t)
|
||||
if err != nil {
|
||||
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "invalid token")
|
||||
return
|
||||
}
|
||||
if ok, err := scope.VerifyScope(ctx, tokenScope, r); err != nil || !ok {
|
||||
opt.Logger.Error().Err(err).Msg("verifying scope failed")
|
||||
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "verifying scope failed")
|
||||
return
|
||||
}
|
||||
|
||||
ctx = revactx.ContextSetToken(ctx, t)
|
||||
ctx = revactx.ContextSetUser(ctx, u)
|
||||
ctx = gmmetadata.Set(ctx, opkgm.AccountID, u.Id.OpaqueId)
|
||||
if u.Opaque != nil && u.Opaque.Map != nil {
|
||||
if roles, ok := u.Opaque.Map["roles"]; ok {
|
||||
ctx = gmmetadata.Set(ctx, opkgm.RoleIDs, string(roles.Value))
|
||||
}
|
||||
}
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, t)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/roles"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
settings "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0"
|
||||
)
|
||||
|
||||
// RequireAdmin middleware is used to require the user in context to be an admin / have account management permissions
|
||||
func RequireAdmin(rm *roles.Manager, logger log.Logger) func(next http.Handler) http.Handler {
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := revactx.ContextGetUser(r.Context())
|
||||
if !ok {
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
if u.Id == nil || u.Id.OpaqueId == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "user is missing an id")
|
||||
return
|
||||
}
|
||||
// get roles from context
|
||||
roleIDs, ok := roles.ReadRoleIDsFromContext(r.Context())
|
||||
if !ok {
|
||||
logger.Debug().Str("userid", u.Id.OpaqueId).Msg("No roles in context, contacting settings service")
|
||||
var err error
|
||||
roleIDs, err = rm.FindRoleIDsForUser(r.Context(), u.Id.OpaqueId)
|
||||
if err != nil {
|
||||
logger.Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to get roles for user")
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
if len(roleIDs) == 0 {
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// check if permission is present in roles of the authenticated account
|
||||
if rm.FindPermissionByID(r.Context(), roleIDs, settings.AccountManagementPermissionID) != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusUnauthorized, "Unauthorized")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/service/debug"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
)
|
||||
|
||||
// Server initializes the debug service and server.
|
||||
func Server(opts ...Option) (*http.Server, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
return debug.NewService(
|
||||
debug.Logger(options.Logger),
|
||||
debug.Name(options.Config.Service.Name),
|
||||
debug.Version(version.GetString()),
|
||||
debug.Address(options.Config.Debug.Addr),
|
||||
debug.Token(options.Config.Debug.Token),
|
||||
debug.Pprof(options.Config.Debug.Pprof),
|
||||
debug.Zpages(options.Config.Debug.Zpages),
|
||||
debug.Health(health(options.Config)),
|
||||
debug.Ready(ready(options.Config)),
|
||||
), nil
|
||||
}
|
||||
|
||||
// health implements the health check.
|
||||
func health(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// TODO: check if services are up and running
|
||||
|
||||
_, err := io.WriteString(w, http.StatusText(http.StatusOK))
|
||||
// io.WriteString should not fail but if it does we want to know.
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ready implements the ready check.
|
||||
func ready(cfg *config.Config) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// TODO: check if services are up and running
|
||||
|
||||
_, err := io.WriteString(w, http.StatusText(http.StatusOK))
|
||||
// io.WriteString should not fail but if it does we want to know.
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/metrics"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
Metrics *metrics.Metrics
|
||||
Flags []cli.Flag
|
||||
Namespace string
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Context provides a function to set the context option.
|
||||
func Context(val context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
return func(o *Options) {
|
||||
o.Metrics = val
|
||||
}
|
||||
}
|
||||
|
||||
// Flags provides a function to set the flags option.
|
||||
func Flags(val []cli.Flag) Option {
|
||||
return func(o *Options) {
|
||||
o.Flags = append(o.Flags, val...)
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace provides a function to set the Namespace option.
|
||||
func Namespace(val string) Option {
|
||||
return func(o *Options) {
|
||||
o.Namespace = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/cs3org/reva/v2/pkg/events/server"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-micro/plugins/v4/events/natsjs"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/account"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/service/http"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/version"
|
||||
graphMiddleware "github.com/owncloud/ocis/v2/services/graph/pkg/middleware"
|
||||
svc "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0"
|
||||
"github.com/pkg/errors"
|
||||
"go-micro.dev/v4"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
func Server(opts ...Option) (http.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
|
||||
service := http.NewService(
|
||||
http.Logger(options.Logger),
|
||||
http.Namespace(options.Config.HTTP.Namespace),
|
||||
http.Name("graph"),
|
||||
http.Version(version.GetString()),
|
||||
http.Address(options.Config.HTTP.Addr),
|
||||
http.Context(options.Context),
|
||||
http.Flags(options.Flags...),
|
||||
)
|
||||
|
||||
publisher, err := server.NewNatsStream(
|
||||
natsjs.Address(options.Config.Events.Endpoint),
|
||||
natsjs.ClusterID(options.Config.Events.Cluster),
|
||||
)
|
||||
if err != nil {
|
||||
options.Logger.Error().
|
||||
Err(err).
|
||||
Msg("Error initializing events publisher")
|
||||
return http.Service{}, errors.Wrap(err, "could not initialize events publisher")
|
||||
}
|
||||
|
||||
handle := svc.NewService(
|
||||
svc.Logger(options.Logger),
|
||||
svc.Config(options.Config),
|
||||
svc.Middleware(
|
||||
chimiddleware.RequestID,
|
||||
middleware.Version(
|
||||
"graph",
|
||||
version.GetString(),
|
||||
),
|
||||
middleware.Logger(
|
||||
options.Logger,
|
||||
),
|
||||
graphMiddleware.Auth(
|
||||
account.Logger(options.Logger),
|
||||
account.JWTSecret(options.Config.TokenManager.JWTSecret),
|
||||
),
|
||||
),
|
||||
svc.EventsPublisher(publisher),
|
||||
)
|
||||
|
||||
if handle == nil {
|
||||
return http.Service{}, errors.New("could not initialize graph service")
|
||||
}
|
||||
|
||||
{
|
||||
handle = svc.NewInstrument(handle, options.Metrics)
|
||||
handle = svc.NewLogging(handle, options.Logger)
|
||||
handle = svc.NewTracing(handle)
|
||||
}
|
||||
|
||||
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/storagespace"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
)
|
||||
|
||||
// GetRootDriveChildren implements the Service interface.
|
||||
func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) {
|
||||
g.logger.Info().Msg("Calling GetRootDriveChildren")
|
||||
ctx := r.Context()
|
||||
|
||||
client := g.GetGatewayClient()
|
||||
|
||||
res, err := client.GetHome(ctx, &storageprovider.GetHomeRequest{})
|
||||
switch {
|
||||
case err != nil:
|
||||
g.logger.Error().Err(err).Msg("error sending get home grpc request")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
case res.Status.Code != cs3rpc.Code_CODE_OK:
|
||||
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.Status.Message)
|
||||
return
|
||||
}
|
||||
g.logger.Error().Err(err).Msg("error sending get home grpc request")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
|
||||
return
|
||||
}
|
||||
|
||||
lRes, err := client.ListContainer(ctx, &storageprovider.ListContainerRequest{
|
||||
Ref: &storageprovider.Reference{
|
||||
Path: res.Path,
|
||||
},
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
g.logger.Error().Err(err).Msg("error sending list container grpc request")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
case res.Status.Code != cs3rpc.Code_CODE_OK:
|
||||
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.Status.Message)
|
||||
return
|
||||
}
|
||||
if res.Status.Code == cs3rpc.Code_CODE_PERMISSION_DENIED {
|
||||
// TODO check if we should return 404 to not disclose existing items
|
||||
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, res.Status.Message)
|
||||
return
|
||||
}
|
||||
g.logger.Error().Err(err).Msg("error sending list container grpc request")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
|
||||
return
|
||||
}
|
||||
|
||||
files, err := formatDriveItems(lRes.Infos)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error encoding response as json")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &listResponse{Value: files})
|
||||
}
|
||||
|
||||
func (g Graph) getDriveItem(ctx context.Context, root storageprovider.ResourceId) (*libregraph.DriveItem, error) {
|
||||
client := g.GetGatewayClient()
|
||||
|
||||
ref := &storageprovider.Reference{
|
||||
ResourceId: &root,
|
||||
}
|
||||
res, err := client.Stat(ctx, &storageprovider.StatRequest{Ref: ref})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Status.Code != cs3rpc.Code_CODE_OK {
|
||||
return nil, fmt.Errorf("could not stat %s: %s", ref, res.Status.Message)
|
||||
}
|
||||
return cs3ResourceToDriveItem(res.Info)
|
||||
}
|
||||
|
||||
func (g Graph) getRemoteItem(ctx context.Context, root *storageprovider.ResourceId, baseURL *url.URL) (*libregraph.RemoteItem, error) {
|
||||
client := g.GetGatewayClient()
|
||||
|
||||
ref := &storageprovider.Reference{
|
||||
ResourceId: root,
|
||||
}
|
||||
res, err := client.Stat(ctx, &storageprovider.StatRequest{Ref: ref})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Status.Code != cs3rpc.Code_CODE_OK {
|
||||
// Only log this, there could be mountpoints which have no grant
|
||||
g.logger.Debug().Msg(res.Status.Message)
|
||||
return nil, errors.New("could not fetch grant resource for the mountpoint")
|
||||
}
|
||||
item, err := cs3ResourceToRemoteItem(res.Info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
item.WebDavUrl = libregraph.PtrString(baseURL.String() + storagespace.FormatResourceID(*root))
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func formatDriveItems(mds []*storageprovider.ResourceInfo) ([]*libregraph.DriveItem, error) {
|
||||
responses := make([]*libregraph.DriveItem, 0, len(mds))
|
||||
for i := range mds {
|
||||
res, err := cs3ResourceToDriveItem(mds[i])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responses = append(responses, res)
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func cs3TimestampToTime(t *types.Timestamp) time.Time {
|
||||
return time.Unix(int64(t.Seconds), int64(t.Nanos))
|
||||
}
|
||||
|
||||
func cs3ResourceToDriveItem(res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) {
|
||||
size := new(int64)
|
||||
*size = int64(res.Size) // TODO lurking overflow: make size of libregraph drive item use uint64
|
||||
|
||||
driveItem := &libregraph.DriveItem{
|
||||
Id: libregraph.PtrString(storagespace.FormatResourceID(*res.Id)),
|
||||
Size: size,
|
||||
}
|
||||
|
||||
if name := path.Base(res.Path); name != "" {
|
||||
driveItem.Name = &name
|
||||
}
|
||||
if res.Etag != "" {
|
||||
driveItem.ETag = &res.Etag
|
||||
}
|
||||
if res.Mtime != nil {
|
||||
lastModified := cs3TimestampToTime(res.Mtime)
|
||||
driveItem.LastModifiedDateTime = &lastModified
|
||||
}
|
||||
if res.Type == storageprovider.ResourceType_RESOURCE_TYPE_FILE && res.MimeType != "" {
|
||||
// We cannot use a libregraph.File here because the openapi codegenerator autodetects 'File' as a go type ...
|
||||
driveItem.File = &libregraph.OpenGraphFile{
|
||||
MimeType: &res.MimeType,
|
||||
}
|
||||
}
|
||||
if res.Type == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER {
|
||||
driveItem.Folder = &libregraph.Folder{}
|
||||
}
|
||||
return driveItem, nil
|
||||
}
|
||||
|
||||
func cs3ResourceToRemoteItem(res *storageprovider.ResourceInfo) (*libregraph.RemoteItem, error) {
|
||||
size := new(int64)
|
||||
*size = int64(res.Size) // TODO lurking overflow: make size of libregraph drive item use uint64
|
||||
|
||||
remoteItem := &libregraph.RemoteItem{
|
||||
Id: libregraph.PtrString(storagespace.FormatResourceID(*res.Id)),
|
||||
Size: size,
|
||||
}
|
||||
|
||||
if name := path.Base(res.Path); name != "" {
|
||||
remoteItem.Name = &name
|
||||
}
|
||||
if res.Etag != "" {
|
||||
remoteItem.ETag = &res.Etag
|
||||
}
|
||||
if res.Mtime != nil {
|
||||
lastModified := cs3TimestampToTime(res.Mtime)
|
||||
remoteItem.LastModifiedDateTime = &lastModified
|
||||
}
|
||||
if res.Type == storageprovider.ResourceType_RESOURCE_TYPE_FILE && res.MimeType != "" {
|
||||
// We cannot use a libregraph.File here because the openapi codegenerator autodetects 'File' as a go type ...
|
||||
remoteItem.File = &libregraph.OpenGraphFile{
|
||||
MimeType: &res.MimeType,
|
||||
}
|
||||
}
|
||||
if res.Type == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER {
|
||||
remoteItem.Folder = &libregraph.Folder{}
|
||||
}
|
||||
return remoteItem, nil
|
||||
}
|
||||
|
||||
func (g Graph) getPathForResource(ctx context.Context, id storageprovider.ResourceId) (string, error) {
|
||||
client := g.GetGatewayClient()
|
||||
res, err := client.GetPath(ctx, &storageprovider.GetPathRequest{ResourceId: &id})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res.Status.Code != cs3rpc.Code_CODE_OK {
|
||||
return "", fmt.Errorf("could not stat %v: %s", id, res.Status.Message)
|
||||
}
|
||||
return res.Path, err
|
||||
}
|
||||
|
||||
// GetExtendedSpaceProperties reads properties from the opaque and transforms them into driveItems
|
||||
func (g Graph) GetExtendedSpaceProperties(ctx context.Context, baseURL *url.URL, space *storageprovider.StorageSpace) []libregraph.DriveItem {
|
||||
var spaceItems []libregraph.DriveItem
|
||||
if space.Opaque == nil {
|
||||
return nil
|
||||
}
|
||||
metadata := space.Opaque.Map
|
||||
names := [2]string{SpaceImageSpecialFolderName, ReadmeSpecialFolderName}
|
||||
|
||||
for _, itemName := range names {
|
||||
if itemID, ok := metadata[itemName]; ok {
|
||||
rid, _ := storagespace.ParseID(string(itemID.Value))
|
||||
spaceItem := g.getSpecialDriveItem(ctx, rid, itemName, baseURL, space)
|
||||
if spaceItem != nil {
|
||||
spaceItems = append(spaceItems, *spaceItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
return spaceItems
|
||||
}
|
||||
|
||||
func (g Graph) getSpecialDriveItem(ctx context.Context, id storageprovider.ResourceId, itemName string, baseURL *url.URL, space *storageprovider.StorageSpace) *libregraph.DriveItem {
|
||||
var spaceItem *libregraph.DriveItem
|
||||
if id.StorageId == "" && id.OpaqueId == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
spaceItem, err := g.getDriveItem(ctx, id)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Str("ID", id.OpaqueId).Msg("Could not get readme Item")
|
||||
return nil
|
||||
}
|
||||
itemPath, err := g.getPathForResource(ctx, id)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Str("ID", id.OpaqueId).Msg("Could not get readme path")
|
||||
return nil
|
||||
}
|
||||
spaceItem.SpecialFolder = &libregraph.SpecialFolder{Name: libregraph.PtrString(itemName)}
|
||||
webdavURL := *baseURL
|
||||
webdavURL.Path = path.Join(webdavURL.Path, space.Id.OpaqueId, itemPath)
|
||||
spaceItem.WebDavUrl = libregraph.PtrString(webdavURL.String())
|
||||
|
||||
return spaceItem
|
||||
}
|
||||
@@ -0,0 +1,851 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/storagespace"
|
||||
"github.com/cs3org/reva/v2/pkg/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
|
||||
v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
settingsServiceExt "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0"
|
||||
merrors "go-micro.dev/v4/errors"
|
||||
)
|
||||
|
||||
// GetDrives lists all drives the current user has access to
|
||||
func (g Graph) GetDrives(w http.ResponseWriter, r *http.Request) {
|
||||
g.getDrives(w, r, false)
|
||||
}
|
||||
|
||||
// GetAllDrives lists all drives, including other user's drives, if the current
|
||||
// user has the permission.
|
||||
func (g Graph) GetAllDrives(w http.ResponseWriter, r *http.Request) {
|
||||
g.getDrives(w, r, true)
|
||||
}
|
||||
|
||||
// getDrives implements the Service interface.
|
||||
func (g Graph) getDrives(w http.ResponseWriter, r *http.Request, unrestricted bool) {
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
// Parse the request with odata parser
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
g.logger.Debug().
|
||||
Interface("query", r.URL.Query()).
|
||||
Bool("unrestricted", unrestricted).
|
||||
Msg("Calling getDrives")
|
||||
ctx := r.Context()
|
||||
|
||||
filters, err := generateCs3Filters(odataReq)
|
||||
if err != nil {
|
||||
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
|
||||
errorcode.NotSupported.Render(w, r, http.StatusNotImplemented, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := g.ListStorageSpacesWithFilters(ctx, filters, unrestricted)
|
||||
switch {
|
||||
case err != nil:
|
||||
g.logger.Error().Err(err).Msg(ListStorageSpacesTransportErr)
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
case res.Status.Code != cs3rpc.Code_CODE_OK:
|
||||
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
// return an empty list
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &listResponse{})
|
||||
return
|
||||
}
|
||||
g.logger.Error().Err(err).Msg(ListStorageSpacesReturnsErr)
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
|
||||
return
|
||||
}
|
||||
|
||||
wdu, err := url.Parse(g.config.Spaces.WebDavBase + g.config.Spaces.WebDavPath)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error parsing url")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
spaces, err := g.formatDrives(ctx, wdu, res.StorageSpaces)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error encoding response as json")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
spaces, err = sortSpaces(odataReq, spaces)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error sorting the spaces list")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &listResponse{Value: spaces})
|
||||
}
|
||||
|
||||
// GetSingleDrive does a lookup of a single space by spaceId
|
||||
func (g Graph) GetSingleDrive(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, _ := url.PathUnescape(chi.URLParam(r, "driveID"))
|
||||
if driveID == "" {
|
||||
err := fmt.Errorf("no valid space id retrieved")
|
||||
g.logger.Err(err)
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
g.logger.Info().Str("driveID", driveID).Msg("Calling GetSingleDrive")
|
||||
ctx := r.Context()
|
||||
|
||||
filters := []*storageprovider.ListStorageSpacesRequest_Filter{listStorageSpacesIDFilter(driveID)}
|
||||
res, err := g.ListStorageSpacesWithFilters(ctx, filters, true)
|
||||
switch {
|
||||
case err != nil:
|
||||
g.logger.Error().Err(err).Msg(ListStorageSpacesTransportErr)
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
case res.Status.Code != cs3rpc.Code_CODE_OK:
|
||||
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
|
||||
// the client is doing a lookup for a specific space, therefore we need to return
|
||||
// not found to the caller
|
||||
g.logger.Error().Str("driveID", driveID).Msg(fmt.Sprintf(NoSpaceFoundMessage, driveID))
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf(NoSpaceFoundMessage, driveID))
|
||||
return
|
||||
}
|
||||
g.logger.Error().Err(err).Msg(ListStorageSpacesReturnsErr)
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.Status.Message)
|
||||
return
|
||||
}
|
||||
|
||||
wdu, err := url.Parse(g.config.Spaces.WebDavBase + g.config.Spaces.WebDavPath)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error parsing url")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
spaces, err := g.formatDrives(ctx, wdu, res.StorageSpaces)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error encoding response")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
switch num := len(spaces); {
|
||||
case num == 0:
|
||||
g.logger.Error().Str("driveID", driveID).Msg("no space found")
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf(NoSpaceFoundMessage, driveID))
|
||||
return
|
||||
case num == 1:
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, spaces[0])
|
||||
default:
|
||||
g.logger.Error().Int("number", num).Msg("expected to find a single space but found more")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "expected to find a single space but found more")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func canCreateSpace(ctx context.Context, ownPersonalHome bool) bool {
|
||||
s := settingssvc.NewPermissionService("com.owncloud.api.settings", grpc.DefaultClient)
|
||||
|
||||
pr, err := s.GetPermissionByID(ctx, &settingssvc.GetPermissionByIDRequest{
|
||||
PermissionId: settingsServiceExt.CreateSpacePermissionID,
|
||||
})
|
||||
if err != nil || pr.Permission == nil {
|
||||
return false
|
||||
}
|
||||
// TODO @C0rby shouldn't the permissions service check this? aka shouldn't we call CheckPermission?
|
||||
if pr.Permission.Constraint == v0.Permission_CONSTRAINT_OWN && !ownPersonalHome {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateDrive creates a storage drive (space).
|
||||
func (g Graph) CreateDrive(w http.ResponseWriter, r *http.Request) {
|
||||
us, ok := ctxpkg.ContextGetUser(r.Context())
|
||||
if !ok {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "invalid user")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO determine if the user tries to create his own personal space and pass that as a boolean
|
||||
if !canCreateSpace(r.Context(), false) {
|
||||
// if the permission is not existing for the user in context we can assume we don't have it. Return 401.
|
||||
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "insufficient permissions to create a space.")
|
||||
return
|
||||
}
|
||||
|
||||
client := g.GetGatewayClient()
|
||||
drive := libregraph.Drive{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&drive); err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, "invalid schema definition")
|
||||
return
|
||||
}
|
||||
spaceName := *drive.Name
|
||||
if spaceName == "" {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "invalid name")
|
||||
return
|
||||
}
|
||||
|
||||
var driveType string
|
||||
if drive.DriveType != nil {
|
||||
driveType = *drive.DriveType
|
||||
}
|
||||
switch driveType {
|
||||
case "", "project":
|
||||
driveType = "project"
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, fmt.Sprintf("drives of type %s cannot be created via this api", driveType))
|
||||
return
|
||||
}
|
||||
|
||||
csr := storageprovider.CreateStorageSpaceRequest{
|
||||
Owner: us,
|
||||
Type: driveType,
|
||||
Name: spaceName,
|
||||
Quota: getQuota(drive.Quota, g.config.Spaces.DefaultQuota),
|
||||
}
|
||||
|
||||
if drive.Description != nil {
|
||||
csr.Opaque = utils.AppendPlainToOpaque(csr.Opaque, "description", *drive.Description)
|
||||
}
|
||||
|
||||
if drive.DriveAlias != nil {
|
||||
csr.Opaque = utils.AppendPlainToOpaque(csr.Opaque, "spaceAlias", *drive.DriveAlias)
|
||||
}
|
||||
|
||||
resp, err := client.CreateStorageSpace(r.Context(), &csr)
|
||||
if err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if resp.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "")
|
||||
return
|
||||
}
|
||||
|
||||
wdu, err := url.Parse(g.config.Spaces.WebDavBase + g.config.Spaces.WebDavPath)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error parsing url")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
newDrive, err := g.cs3StorageSpaceToDrive(r.Context(), wdu, resp.StorageSpace)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error parsing space")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, newDrive)
|
||||
}
|
||||
|
||||
func (g Graph) UpdateDrive(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, err := url.PathUnescape(chi.URLParam(r, "driveID"))
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping drive id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if driveID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing drive id")
|
||||
return
|
||||
}
|
||||
|
||||
drive := libregraph.Drive{}
|
||||
if err = json.NewDecoder(r.Body).Decode(&drive); err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", r.Body))
|
||||
return
|
||||
}
|
||||
|
||||
root := &storageprovider.ResourceId{}
|
||||
|
||||
identifierParts := strings.Split(driveID, "!")
|
||||
switch len(identifierParts) {
|
||||
case 1:
|
||||
_, sID := storagespace.SplitStorageID(identifierParts[0])
|
||||
root.StorageId, root.OpaqueId = identifierParts[0], sID
|
||||
case 2:
|
||||
root.StorageId, root.OpaqueId = identifierParts[0], identifierParts[1]
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid resource id: %v", driveID))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
client := g.GetGatewayClient()
|
||||
|
||||
updateSpaceRequest := &storageprovider.UpdateStorageSpaceRequest{
|
||||
// Prepare the object to apply the diff from. The properties on StorageSpace will overwrite
|
||||
// the original storage space.
|
||||
StorageSpace: &storageprovider.StorageSpace{
|
||||
Id: &storageprovider.StorageSpaceId{
|
||||
OpaqueId: root.StorageId + "!" + root.OpaqueId,
|
||||
},
|
||||
Root: root,
|
||||
},
|
||||
}
|
||||
|
||||
// Note: this is the Opaque prop of the request
|
||||
if restore, _ := strconv.ParseBool(r.Header.Get("restore")); restore {
|
||||
updateSpaceRequest.Opaque = &types.Opaque{
|
||||
Map: map[string]*types.OpaqueEntry{
|
||||
"restore": {
|
||||
Decoder: "plain",
|
||||
Value: []byte("true"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if drive.Description != nil {
|
||||
updateSpaceRequest.StorageSpace.Opaque = utils.AppendPlainToOpaque(updateSpaceRequest.StorageSpace.Opaque, "description", *drive.Description)
|
||||
}
|
||||
|
||||
if drive.DriveAlias != nil {
|
||||
updateSpaceRequest.StorageSpace.Opaque = utils.AppendPlainToOpaque(updateSpaceRequest.StorageSpace.Opaque, "spaceAlias", *drive.DriveAlias)
|
||||
}
|
||||
|
||||
for _, special := range drive.Special {
|
||||
if special.Id != nil {
|
||||
updateSpaceRequest.StorageSpace.Opaque = utils.AppendPlainToOpaque(updateSpaceRequest.StorageSpace.Opaque, *special.SpecialFolder.Name, *special.Id)
|
||||
}
|
||||
}
|
||||
|
||||
if drive.Name != nil {
|
||||
updateSpaceRequest.StorageSpace.Name = *drive.Name
|
||||
}
|
||||
|
||||
if drive.Quota.HasTotal() {
|
||||
user := ctxpkg.ContextMustGetUser(r.Context())
|
||||
canSetSpaceQuota, err := canSetSpaceQuota(r.Context(), user)
|
||||
if err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !canSetSpaceQuota {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusUnauthorized, "user is not allowed to set the space quota")
|
||||
return
|
||||
}
|
||||
updateSpaceRequest.StorageSpace.Quota = &storageprovider.Quota{
|
||||
QuotaMaxBytes: uint64(*drive.Quota.Total),
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.UpdateStorageSpace(r.Context(), updateSpaceRequest)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
|
||||
switch resp.Status.GetCode() {
|
||||
case cs3rpc.Code_CODE_NOT_FOUND:
|
||||
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, resp.GetStatus().GetMessage())
|
||||
return
|
||||
case cs3rpc.Code_CODE_PERMISSION_DENIED:
|
||||
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, resp.GetStatus().GetMessage())
|
||||
return
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, resp.GetStatus().GetMessage())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
wdu, err := url.Parse(g.config.Spaces.WebDavBase + g.config.Spaces.WebDavPath)
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error parsing url")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
spaces, err := g.formatDrives(r.Context(), wdu, []*storageprovider.StorageSpace{resp.StorageSpace})
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error parsing space")
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, spaces[0])
|
||||
}
|
||||
|
||||
func (g Graph) formatDrives(ctx context.Context, baseURL *url.URL, storageSpaces []*storageprovider.StorageSpace) ([]*libregraph.Drive, error) {
|
||||
responses := make([]*libregraph.Drive, 0, len(storageSpaces))
|
||||
for _, storageSpace := range storageSpaces {
|
||||
res, err := g.cs3StorageSpaceToDrive(ctx, baseURL, storageSpace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// can't access disabled space
|
||||
if utils.ReadPlainFromOpaque(storageSpace.Opaque, "trashed") != "trashed" {
|
||||
res.Special = g.GetExtendedSpaceProperties(ctx, baseURL, storageSpace)
|
||||
res.Quota, err = g.getDriveQuota(ctx, storageSpace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
responses = append(responses, res)
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
// ListStorageSpacesWithFilters List Storage Spaces using filters
|
||||
func (g Graph) ListStorageSpacesWithFilters(ctx context.Context, filters []*storageprovider.ListStorageSpacesRequest_Filter, unrestricted bool) (*storageprovider.ListStorageSpacesResponse, error) {
|
||||
client := g.GetGatewayClient()
|
||||
|
||||
permissions := make(map[string]struct{}, 1)
|
||||
s := settingssvc.NewPermissionService("com.owncloud.api.settings", grpc.DefaultClient)
|
||||
|
||||
_, err := s.GetPermissionByID(ctx, &settingssvc.GetPermissionByIDRequest{
|
||||
PermissionId: settingsServiceExt.ListAllSpacesPermissionID,
|
||||
})
|
||||
|
||||
// No error means the user has the permission
|
||||
if err == nil {
|
||||
permissions[settingsServiceExt.ListAllSpacesPermissionName] = struct{}{}
|
||||
}
|
||||
value, err := json.Marshal(permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := client.ListStorageSpaces(ctx, &storageprovider.ListStorageSpacesRequest{
|
||||
Opaque: &types.Opaque{Map: map[string]*types.OpaqueEntry{
|
||||
"permissions": {
|
||||
Decoder: "json",
|
||||
Value: value,
|
||||
},
|
||||
"unrestricted": {
|
||||
Decoder: "plain",
|
||||
Value: []byte(strconv.FormatBool(unrestricted)),
|
||||
},
|
||||
}},
|
||||
Filters: filters,
|
||||
})
|
||||
return res, err
|
||||
}
|
||||
|
||||
func generateSpaceId(id *storageprovider.ResourceId) (spaceID string) {
|
||||
spaceID = id.GetStorageId()
|
||||
// 2nd ID to compare is the opaque ID of the Space Root
|
||||
spaceID2 := id.GetOpaqueId()
|
||||
if strings.Contains(spaceID, "$") {
|
||||
_, spaceID2 = storagespace.SplitStorageID(spaceID)
|
||||
}
|
||||
// Append opaqueID only if it is different from the spaceID2
|
||||
if id.OpaqueId != spaceID2 {
|
||||
spaceID += "!" + id.OpaqueId
|
||||
}
|
||||
return spaceID
|
||||
}
|
||||
|
||||
func (g Graph) cs3StorageSpaceToDrive(ctx context.Context, baseURL *url.URL, space *storageprovider.StorageSpace) (*libregraph.Drive, error) {
|
||||
if space.Root == nil {
|
||||
return nil, fmt.Errorf("space has no root")
|
||||
}
|
||||
spaceID := generateSpaceId(space.Root)
|
||||
|
||||
var permissions []libregraph.Permission
|
||||
if space.Opaque != nil {
|
||||
var m map[string]*storageprovider.ResourcePermissions
|
||||
entry, ok := space.Opaque.Map["grants"]
|
||||
if ok {
|
||||
err := json.Unmarshal(entry.Value, &m)
|
||||
if err != nil {
|
||||
g.logger.Error().
|
||||
Err(err).
|
||||
Str("space", space.Root.OpaqueId).
|
||||
Msg("failed to read spaces grants")
|
||||
}
|
||||
}
|
||||
if len(m) != 0 {
|
||||
managerIdentities := []libregraph.IdentitySet{}
|
||||
editorIdentities := []libregraph.IdentitySet{}
|
||||
viewerIdentities := []libregraph.IdentitySet{}
|
||||
|
||||
for id, perm := range m {
|
||||
// This temporary variable is necessary since we need to pass a pointer to the
|
||||
// libregraph.Identity and if we pass the pointer from the loop every identity
|
||||
// will have the same id.
|
||||
tmp := id
|
||||
identity := libregraph.IdentitySet{User: &libregraph.Identity{Id: &tmp}}
|
||||
// we need to map the permissions to the roles
|
||||
switch {
|
||||
// having RemoveGrant qualifies you as a manager
|
||||
case perm.RemoveGrant:
|
||||
managerIdentities = append(managerIdentities, identity)
|
||||
// InitiateFileUpload means you are an editor
|
||||
case perm.InitiateFileUpload:
|
||||
editorIdentities = append(editorIdentities, identity)
|
||||
// Stat permission at least makes you a viewer
|
||||
case perm.Stat:
|
||||
viewerIdentities = append(viewerIdentities, identity)
|
||||
}
|
||||
}
|
||||
|
||||
permissions = make([]libregraph.Permission, 0, 3)
|
||||
if len(managerIdentities) != 0 {
|
||||
permissions = append(permissions, libregraph.Permission{
|
||||
GrantedTo: managerIdentities,
|
||||
Roles: []string{"manager"},
|
||||
})
|
||||
}
|
||||
if len(editorIdentities) != 0 {
|
||||
permissions = append(permissions, libregraph.Permission{
|
||||
GrantedTo: editorIdentities,
|
||||
Roles: []string{"editor"},
|
||||
})
|
||||
}
|
||||
if len(viewerIdentities) != 0 {
|
||||
permissions = append(permissions, libregraph.Permission{
|
||||
GrantedTo: viewerIdentities,
|
||||
Roles: []string{"viewer"},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drive := &libregraph.Drive{
|
||||
Id: libregraph.PtrString(spaceID),
|
||||
Name: &space.Name,
|
||||
//"createdDateTime": "string (timestamp)", // TODO read from StorageSpace ... needs Opaque for now
|
||||
//"description": "string", // TODO read from StorageSpace ... needs Opaque for now
|
||||
DriveType: &space.SpaceType,
|
||||
Root: &libregraph.DriveItem{
|
||||
Id: libregraph.PtrString(storagespace.FormatResourceID(*space.Root)),
|
||||
Permissions: permissions,
|
||||
},
|
||||
}
|
||||
if space.SpaceType == "mountpoint" {
|
||||
var remoteItem *libregraph.RemoteItem
|
||||
grantID := storageprovider.ResourceId{
|
||||
StorageId: utils.ReadPlainFromOpaque(space.Opaque, "grantStorageID"),
|
||||
OpaqueId: utils.ReadPlainFromOpaque(space.Opaque, "grantOpaqueID"),
|
||||
}
|
||||
if grantID.StorageId != "" && grantID.OpaqueId != "" {
|
||||
var err error
|
||||
remoteItem, err = g.getRemoteItem(ctx, &grantID, baseURL)
|
||||
if err != nil {
|
||||
g.logger.Debug().Err(err).Msg(err.Error())
|
||||
}
|
||||
}
|
||||
if remoteItem != nil {
|
||||
drive.Root.RemoteItem = remoteItem
|
||||
}
|
||||
}
|
||||
|
||||
if space.Opaque != nil {
|
||||
if description, ok := space.Opaque.Map["description"]; ok {
|
||||
drive.Description = libregraph.PtrString(string(description.Value))
|
||||
}
|
||||
|
||||
if alias, ok := space.Opaque.Map["spaceAlias"]; ok {
|
||||
drive.DriveAlias = libregraph.PtrString(string(alias.Value))
|
||||
}
|
||||
|
||||
if v, ok := space.Opaque.Map["trashed"]; ok {
|
||||
deleted := &libregraph.Deleted{}
|
||||
deleted.SetState(string(v.Value))
|
||||
drive.Root.Deleted = deleted
|
||||
}
|
||||
|
||||
if entry, ok := space.Opaque.Map["etag"]; ok {
|
||||
drive.Root.ETag = libregraph.PtrString(string(entry.Value))
|
||||
}
|
||||
}
|
||||
|
||||
if baseURL != nil {
|
||||
// TODO read from StorageSpace ... needs Opaque for now
|
||||
// TODO how do we build the url?
|
||||
// for now: read from request
|
||||
webDavURL := *baseURL
|
||||
webDavURL.Path = path.Join(webDavURL.Path, spaceID)
|
||||
drive.Root.WebDavUrl = libregraph.PtrString(webDavURL.String())
|
||||
}
|
||||
|
||||
// TODO The public space has no owner ... should we even show it?
|
||||
if space.Owner != nil && space.Owner.Id != nil {
|
||||
drive.Owner = &libregraph.IdentitySet{
|
||||
User: &libregraph.Identity{
|
||||
Id: &space.Owner.Id.OpaqueId,
|
||||
// DisplayName: , TODO read and cache from users provider
|
||||
},
|
||||
}
|
||||
}
|
||||
if space.Mtime != nil {
|
||||
lastModified := cs3TimestampToTime(space.Mtime)
|
||||
drive.LastModifiedDateTime = &lastModified
|
||||
}
|
||||
if space.Quota != nil {
|
||||
var t int64
|
||||
if space.Quota.QuotaMaxBytes > math.MaxInt64 {
|
||||
t = math.MaxInt64
|
||||
} else {
|
||||
t = int64(space.Quota.QuotaMaxBytes)
|
||||
}
|
||||
drive.Quota = &libregraph.Quota{
|
||||
Total: &t,
|
||||
}
|
||||
}
|
||||
// FIXME use coowner from https://github.com/owncloud/open-graph-api
|
||||
|
||||
return drive, nil
|
||||
}
|
||||
|
||||
func (g Graph) getDriveQuota(ctx context.Context, space *storageprovider.StorageSpace) (*libregraph.Quota, error) {
|
||||
client := g.GetGatewayClient()
|
||||
|
||||
req := &gateway.GetQuotaRequest{
|
||||
Ref: &storageprovider.Reference{
|
||||
ResourceId: &storageprovider.ResourceId{
|
||||
StorageId: space.Root.StorageId,
|
||||
OpaqueId: space.Root.OpaqueId,
|
||||
},
|
||||
Path: ".",
|
||||
},
|
||||
}
|
||||
res, err := client.GetQuota(ctx, req)
|
||||
switch {
|
||||
case err != nil:
|
||||
g.logger.Error().Err(err).Msg("could not call GetQuota")
|
||||
return nil, nil
|
||||
case res.Status.Code == cs3rpc.Code_CODE_UNIMPLEMENTED:
|
||||
// TODO well duh
|
||||
return nil, nil
|
||||
case res.Status.Code != cs3rpc.Code_CODE_OK:
|
||||
g.logger.Error().Err(err).Msg("error sending get quota grpc request")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var remaining int64
|
||||
if res.Opaque != nil {
|
||||
m := res.Opaque.Map
|
||||
if e, ok := m["remaining"]; ok {
|
||||
remaining, _ = strconv.ParseInt(string(e.Value), 10, 64)
|
||||
}
|
||||
}
|
||||
|
||||
used := int64(res.UsedBytes)
|
||||
qta := libregraph.Quota{
|
||||
Remaining: &remaining,
|
||||
Used: &used,
|
||||
}
|
||||
|
||||
var t int64
|
||||
if total := int64(res.TotalBytes); total != 0 {
|
||||
|
||||
// A quota was set
|
||||
qta.Total = &total
|
||||
t = total
|
||||
} else {
|
||||
// Quota was not set
|
||||
// Use remaining bytes to calculate state
|
||||
t = remaining
|
||||
}
|
||||
state := calculateQuotaState(t, used)
|
||||
qta.State = &state
|
||||
|
||||
return &qta, nil
|
||||
}
|
||||
|
||||
func calculateQuotaState(total int64, used int64) (state string) {
|
||||
percent := (float64(used) / float64(total)) * 100
|
||||
|
||||
switch {
|
||||
case percent <= float64(75):
|
||||
return "normal"
|
||||
case percent <= float64(90):
|
||||
return "nearing"
|
||||
case percent <= float64(99):
|
||||
return "critical"
|
||||
default:
|
||||
return "exceeded"
|
||||
}
|
||||
}
|
||||
|
||||
func getQuota(quota *libregraph.Quota, defaultQuota string) *storageprovider.Quota {
|
||||
switch {
|
||||
case quota != nil && quota.Total != nil:
|
||||
if q := *quota.Total; q >= 0 {
|
||||
return &storageprovider.Quota{QuotaMaxBytes: uint64(q)}
|
||||
}
|
||||
fallthrough
|
||||
case defaultQuota != "":
|
||||
if q, err := strconv.ParseInt(defaultQuota, 10, 64); err == nil && q >= 0 {
|
||||
return &storageprovider.Quota{QuotaMaxBytes: uint64(q)}
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func canSetSpaceQuota(ctx context.Context, user *userv1beta1.User) (bool, error) {
|
||||
settingsService := settingssvc.NewPermissionService("com.owncloud.api.settings", grpc.DefaultClient)
|
||||
_, err := settingsService.GetPermissionByID(ctx, &settingssvc.GetPermissionByIDRequest{PermissionId: settingsServiceExt.SetSpaceQuotaPermissionID})
|
||||
if err != nil {
|
||||
merror := merrors.FromError(err)
|
||||
if merror.Status == http.StatusText(http.StatusNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func generateCs3Filters(request *godata.GoDataRequest) ([]*storageprovider.ListStorageSpacesRequest_Filter, error) {
|
||||
var filters []*storageprovider.ListStorageSpacesRequest_Filter
|
||||
if request.Query.Filter != nil {
|
||||
if request.Query.Filter.Tree.Token.Value == "eq" {
|
||||
switch request.Query.Filter.Tree.Children[0].Token.Value {
|
||||
case "driveType":
|
||||
filters = append(filters, listStorageSpacesTypeFilter(strings.Trim(request.Query.Filter.Tree.Children[1].Token.Value, "'")))
|
||||
case "id":
|
||||
filters = append(filters, listStorageSpacesIDFilter(strings.Trim(request.Query.Filter.Tree.Children[1].Token.Value, "'")))
|
||||
}
|
||||
} else {
|
||||
err := fmt.Errorf("unsupported filter operand: %s", request.Query.Filter.Tree.Token.Value)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func listStorageSpacesIDFilter(id string) *storageprovider.ListStorageSpacesRequest_Filter {
|
||||
return &storageprovider.ListStorageSpacesRequest_Filter{
|
||||
Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_ID,
|
||||
Term: &storageprovider.ListStorageSpacesRequest_Filter_Id{
|
||||
Id: &storageprovider.StorageSpaceId{
|
||||
OpaqueId: id,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func listStorageSpacesTypeFilter(spaceType string) *storageprovider.ListStorageSpacesRequest_Filter {
|
||||
return &storageprovider.ListStorageSpacesRequest_Filter{
|
||||
Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE,
|
||||
Term: &storageprovider.ListStorageSpacesRequest_Filter_SpaceType{
|
||||
SpaceType: spaceType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (g Graph) DeleteDrive(w http.ResponseWriter, r *http.Request) {
|
||||
driveID, err := url.PathUnescape(chi.URLParam(r, "driveID"))
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping drive id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if driveID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing drive id")
|
||||
return
|
||||
}
|
||||
|
||||
root := &storageprovider.ResourceId{}
|
||||
|
||||
identifierParts := strings.Split(driveID, "!")
|
||||
_, sID := storagespace.SplitStorageID(identifierParts[0])
|
||||
switch len(identifierParts) {
|
||||
case 1:
|
||||
root.StorageId, root.OpaqueId = identifierParts[0], sID
|
||||
case 2:
|
||||
root.StorageId, root.OpaqueId = identifierParts[0], identifierParts[1]
|
||||
default:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid resource id: %v", driveID))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
purge := parsePurgeHeader(r.Header)
|
||||
|
||||
var opaque *types.Opaque
|
||||
if purge {
|
||||
opaque = &types.Opaque{
|
||||
Map: map[string]*types.OpaqueEntry{
|
||||
"purge": {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
dRes, err := g.gatewayClient.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{
|
||||
Opaque: opaque,
|
||||
Id: &storageprovider.StorageSpaceId{
|
||||
OpaqueId: root.StorageId,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
g.logger.Error().Err(err).Msg("error deleting storage space")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
switch dRes.GetStatus().GetCode() {
|
||||
case cs3rpc.Code_CODE_OK:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
case cs3rpc.Code_CODE_INVALID_ARGUMENT:
|
||||
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, dRes.Status.Message)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
case cs3rpc.Code_CODE_PERMISSION_DENIED:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
// don't expose internal error codes to the outside world
|
||||
default:
|
||||
g.logger.Error().Err(err).Msg("error deleting storage space")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func sortSpaces(req *godata.GoDataRequest, spaces []*libregraph.Drive) ([]*libregraph.Drive, error) {
|
||||
var sorter sort.Interface
|
||||
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
|
||||
return spaces, nil
|
||||
}
|
||||
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
|
||||
case "name":
|
||||
sorter = spacesByName{spaces}
|
||||
case "lastModifiedDateTime":
|
||||
sorter = spacesByLastModifiedDateTime{spaces}
|
||||
default:
|
||||
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
|
||||
}
|
||||
|
||||
if req.Query.OrderBy.OrderByItems[0].Order == "desc" {
|
||||
sorter = sort.Reverse(sorter)
|
||||
}
|
||||
sort.Sort(sorter)
|
||||
return spaces, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type sortTest struct {
|
||||
Drives []*libregraph.Drive
|
||||
Query godata.GoDataRequest
|
||||
DrivesSorted []*libregraph.Drive
|
||||
}
|
||||
|
||||
var time1 = time.Date(2022, 02, 02, 15, 00, 00, 00, time.UTC)
|
||||
var time2 = time.Date(2022, 02, 03, 15, 00, 00, 00, time.UTC)
|
||||
var time3, time5, time6 *time.Time
|
||||
var time4 = time.Date(2022, 02, 05, 15, 00, 00, 00, time.UTC)
|
||||
var drives = []*libregraph.Drive{
|
||||
drive("3", "project", "Admin", time3),
|
||||
drive("1", "project", "Einstein", &time1),
|
||||
drive("2", "project", "Marie", &time2),
|
||||
drive("4", "project", "Richard", &time4),
|
||||
}
|
||||
var drivesLong = append(drives, []*libregraph.Drive{
|
||||
drive("5", "project", "bob", time5),
|
||||
drive("6", "project", "alice", time6),
|
||||
}...)
|
||||
|
||||
var sortTests = []sortTest{
|
||||
{
|
||||
Drives: drives,
|
||||
Query: godata.GoDataRequest{
|
||||
Query: &godata.GoDataQuery{
|
||||
OrderBy: &godata.GoDataOrderByQuery{
|
||||
OrderByItems: []*godata.OrderByItem{
|
||||
{Field: &godata.Token{Value: "name"}, Order: "asc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DrivesSorted: []*libregraph.Drive{
|
||||
drive("3", "project", "Admin", time3),
|
||||
drive("1", "project", "Einstein", &time1),
|
||||
drive("2", "project", "Marie", &time2),
|
||||
drive("4", "project", "Richard", &time4),
|
||||
},
|
||||
},
|
||||
{
|
||||
Drives: drives,
|
||||
Query: godata.GoDataRequest{
|
||||
Query: &godata.GoDataQuery{
|
||||
OrderBy: &godata.GoDataOrderByQuery{
|
||||
OrderByItems: []*godata.OrderByItem{
|
||||
{Field: &godata.Token{Value: "name"}, Order: "desc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DrivesSorted: []*libregraph.Drive{
|
||||
drive("4", "project", "Richard", &time4),
|
||||
drive("2", "project", "Marie", &time2),
|
||||
drive("1", "project", "Einstein", &time1),
|
||||
drive("3", "project", "Admin", time3),
|
||||
},
|
||||
},
|
||||
{
|
||||
Drives: drivesLong,
|
||||
Query: godata.GoDataRequest{
|
||||
Query: &godata.GoDataQuery{
|
||||
OrderBy: &godata.GoDataOrderByQuery{
|
||||
OrderByItems: []*godata.OrderByItem{
|
||||
{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: "asc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DrivesSorted: []*libregraph.Drive{
|
||||
drive("3", "project", "Admin", time3),
|
||||
drive("6", "project", "alice", time6),
|
||||
drive("5", "project", "bob", time5),
|
||||
drive("1", "project", "Einstein", &time1),
|
||||
drive("2", "project", "Marie", &time2),
|
||||
drive("4", "project", "Richard", &time4),
|
||||
},
|
||||
},
|
||||
{
|
||||
Drives: drivesLong,
|
||||
Query: godata.GoDataRequest{
|
||||
Query: &godata.GoDataQuery{
|
||||
OrderBy: &godata.GoDataOrderByQuery{
|
||||
OrderByItems: []*godata.OrderByItem{
|
||||
{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: "desc"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
DrivesSorted: []*libregraph.Drive{
|
||||
drive("4", "project", "Richard", &time4),
|
||||
drive("2", "project", "Marie", &time2),
|
||||
drive("1", "project", "Einstein", &time1),
|
||||
drive("5", "project", "bob", time5),
|
||||
drive("6", "project", "alice", time6),
|
||||
drive("3", "project", "Admin", time3),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func drive(ID string, dType string, name string, lastModified *time.Time) *libregraph.Drive {
|
||||
return &libregraph.Drive{Id: libregraph.PtrString(ID), DriveType: libregraph.PtrString(dType), Name: libregraph.PtrString(name), LastModifiedDateTime: lastModified}
|
||||
}
|
||||
|
||||
// TestSort tests the available orderby queries
|
||||
func TestSort(t *testing.T) {
|
||||
for _, test := range sortTests {
|
||||
sorted, err := sortSpaces(&test.Query, test.Drives)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, test.DrivesSorted, sorted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package errorcode
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
)
|
||||
|
||||
// ErrorCode defines code as used in MS Graph - see https://docs.microsoft.com/en-us/graph/errors?context=graph%2Fapi%2F1.0&view=graph-rest-1.0
|
||||
type ErrorCode int
|
||||
|
||||
type Error struct {
|
||||
errorCode ErrorCode
|
||||
msg string
|
||||
}
|
||||
|
||||
const (
|
||||
// AccessDenied defines the error if the caller doesn't have permission to perform the action.
|
||||
AccessDenied ErrorCode = iota
|
||||
// ActivityLimitReached defines the error if the app or user has been throttled.
|
||||
ActivityLimitReached
|
||||
// GeneralException defines the error if an unspecified error has occurred.
|
||||
GeneralException
|
||||
// InvalidAuthenticationToken defines the error if the access token is missing
|
||||
InvalidAuthenticationToken
|
||||
// InvalidRange defines the error if the specified byte range is invalid or unavailable.
|
||||
InvalidRange
|
||||
// InvalidRequest defines the error if the request is malformed or incorrect.
|
||||
InvalidRequest
|
||||
// ItemNotFound defines the error if the resource could not be found.
|
||||
ItemNotFound
|
||||
// MalwareDetected defines the error if malware was detected in the requested resource.
|
||||
MalwareDetected
|
||||
// NameAlreadyExists defines the error if the specified item name already exists.
|
||||
NameAlreadyExists
|
||||
// NotAllowed defines the error if the action is not allowed by the system.
|
||||
NotAllowed
|
||||
// NotSupported defines the error if the request is not supported by the system.
|
||||
NotSupported
|
||||
// ResourceModified defines the error if the resource being updated has changed since the caller last read it, usually an eTag mismatch.
|
||||
ResourceModified
|
||||
// ResyncRequired defines the error if the delta token is no longer valid, and the app must reset the sync state.
|
||||
ResyncRequired
|
||||
// ServiceNotAvailable defines the error if the service is not available. Try the request again after a delay. There may be a Retry-After header.
|
||||
ServiceNotAvailable
|
||||
// QuotaLimitReached the user has reached their quota limit.
|
||||
QuotaLimitReached
|
||||
// Unauthenticated the caller is not authenticated.
|
||||
Unauthenticated
|
||||
)
|
||||
|
||||
var errorCodes = [...]string{
|
||||
"accessDenied",
|
||||
"activityLimitReached",
|
||||
"generalException",
|
||||
"InvalidAuthenticationToken",
|
||||
"invalidRange",
|
||||
"invalidRequest",
|
||||
"itemNotFound",
|
||||
"malwareDetected",
|
||||
"nameAlreadyExists",
|
||||
"notAllowed",
|
||||
"notSupported",
|
||||
"resourceModified",
|
||||
"resyncRequired",
|
||||
"serviceNotAvailable",
|
||||
"quotaLimitReached",
|
||||
"unauthenticated",
|
||||
}
|
||||
|
||||
func New(e ErrorCode, msg string) Error {
|
||||
return Error{
|
||||
errorCode: e,
|
||||
msg: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// Render writes an Graph ErrorObject to the response writer
|
||||
func (e ErrorCode) Render(w http.ResponseWriter, r *http.Request, status int, msg string) {
|
||||
innererror := map[string]interface{}{
|
||||
"date": time.Now().UTC().Format(time.RFC3339),
|
||||
// TODO return client-request-id?
|
||||
}
|
||||
|
||||
innererror["request-id"] = middleware.GetReqID(r.Context())
|
||||
resp := &libregraph.OdataError{
|
||||
Error: libregraph.OdataErrorMain{
|
||||
Code: e.String(),
|
||||
Message: msg,
|
||||
Innererror: innererror,
|
||||
},
|
||||
}
|
||||
render.Status(r, status)
|
||||
render.JSON(w, r, resp)
|
||||
}
|
||||
|
||||
func (e Error) Render(w http.ResponseWriter, r *http.Request) {
|
||||
status := http.StatusInternalServerError
|
||||
if e.errorCode == ItemNotFound {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
e.errorCode.Render(w, r, status, e.msg)
|
||||
}
|
||||
|
||||
func (e ErrorCode) String() string {
|
||||
return errorCodes[e]
|
||||
}
|
||||
|
||||
func (e Error) Error() string {
|
||||
return errorCodes[e.errorCode]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/ReneKroon/ttlcache/v2"
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/events"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/identity"
|
||||
mevents "go-micro.dev/v4/events"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
//go:generate make -C ../../.. generate
|
||||
|
||||
// GatewayClient is the subset of the gateway.GatewayAPIClient that is being used to interact with the gateway
|
||||
type GatewayClient interface {
|
||||
//gateway.GatewayAPIClient
|
||||
|
||||
// Authenticates a user.
|
||||
Authenticate(ctx context.Context, in *gateway.AuthenticateRequest, opts ...grpc.CallOption) (*gateway.AuthenticateResponse, error)
|
||||
// Returns the home path for the given authenticated user.
|
||||
// When a user has access to multiple storage providers, one of them is the home.
|
||||
GetHome(ctx context.Context, in *provider.GetHomeRequest, opts ...grpc.CallOption) (*provider.GetHomeResponse, error)
|
||||
// GetPath does a path lookup for a resource by ID
|
||||
GetPath(ctx context.Context, in *provider.GetPathRequest, opts ...grpc.CallOption) (*provider.GetPathResponse, error)
|
||||
// Returns a list of resource information
|
||||
// for the provided reference.
|
||||
// MUST return CODE_NOT_FOUND if the reference does not exists.
|
||||
ListContainer(ctx context.Context, in *provider.ListContainerRequest, opts ...grpc.CallOption) (*provider.ListContainerResponse, error)
|
||||
// Returns the resource information at the provided reference.
|
||||
// MUST return CODE_NOT_FOUND if the reference does not exist.
|
||||
Stat(ctx context.Context, in *provider.StatRequest, opts ...grpc.CallOption) (*provider.StatResponse, error)
|
||||
// Initiates the download of a file using an
|
||||
// out-of-band data transfer mechanism.
|
||||
InitiateFileDownload(ctx context.Context, in *provider.InitiateFileDownloadRequest, opts ...grpc.CallOption) (*gateway.InitiateFileDownloadResponse, error)
|
||||
// Creates a storage space.
|
||||
CreateStorageSpace(ctx context.Context, in *provider.CreateStorageSpaceRequest, opts ...grpc.CallOption) (*provider.CreateStorageSpaceResponse, error)
|
||||
// Lists storage spaces.
|
||||
ListStorageSpaces(ctx context.Context, in *provider.ListStorageSpacesRequest, opts ...grpc.CallOption) (*provider.ListStorageSpacesResponse, error)
|
||||
// Updates a storage space.
|
||||
UpdateStorageSpace(ctx context.Context, in *provider.UpdateStorageSpaceRequest, opts ...grpc.CallOption) (*provider.UpdateStorageSpaceResponse, error)
|
||||
// Deletes a storage space.
|
||||
DeleteStorageSpace(ctx context.Context, in *provider.DeleteStorageSpaceRequest, opts ...grpc.CallOption) (*provider.DeleteStorageSpaceResponse, error)
|
||||
// Returns the quota available under the provided
|
||||
// reference.
|
||||
// MUST return CODE_NOT_FOUND if the reference does not exist
|
||||
// MUST return CODE_RESOURCE_EXHAUSTED on exceeded quota limits.
|
||||
GetQuota(ctx context.Context, in *gateway.GetQuotaRequest, opts ...grpc.CallOption) (*provider.GetQuotaResponse, error)
|
||||
}
|
||||
|
||||
// Publisher is the interface for events publisher
|
||||
type Publisher interface {
|
||||
Publish(string, interface{}, ...mevents.PublishOption) error
|
||||
}
|
||||
|
||||
// HTTPClient is the subset of the http.Client that is being used to interact with the download gateway
|
||||
type HTTPClient interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// GetGatewayServiceClientFunc is a callback used to pass in a mock during testing
|
||||
type GetGatewayServiceClientFunc func() (GatewayClient, error)
|
||||
|
||||
// Graph defines implements the business logic for Service.
|
||||
type Graph struct {
|
||||
config *config.Config
|
||||
mux *chi.Mux
|
||||
logger *log.Logger
|
||||
identityBackend identity.Backend
|
||||
gatewayClient GatewayClient
|
||||
httpClient HTTPClient
|
||||
roleService settingssvc.RoleService
|
||||
spacePropertiesCache *ttlcache.Cache
|
||||
eventsPublisher events.Publisher
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (g Graph) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
g.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetClient returns a gateway client to talk to reva
|
||||
func (g Graph) GetGatewayClient() GatewayClient {
|
||||
return g.gatewayClient
|
||||
}
|
||||
|
||||
// GetClient returns a gateway client to talk to reva
|
||||
func (g Graph) GetHTTPClient() HTTPClient {
|
||||
return g.httpClient
|
||||
}
|
||||
|
||||
func (g Graph) publishEvent(ev interface{}) {
|
||||
if err := events.Publish(g.eventsPublisher, ev); err != nil {
|
||||
g.logger.Error().
|
||||
Err(err).
|
||||
Msg("could not publish user created event")
|
||||
}
|
||||
}
|
||||
|
||||
type listResponse struct {
|
||||
Value interface{} `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
NoSpaceFoundMessage = "space with id `%s` not found"
|
||||
ListStorageSpacesTransportErr = "transport error sending list storage spaces grpc request"
|
||||
ListStorageSpacesReturnsErr = "list storage spaces grpc request returns an errorcode in the response"
|
||||
ReadmeSpecialFolderName = "readme"
|
||||
SpaceImageSpecialFolderName = "image"
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestGraph(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Graph Suite")
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/status"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/services/graph/mocks"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
|
||||
service "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Graph", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
gatewayClient *mocks.GatewayClient
|
||||
httpClient *mocks.HTTPClient
|
||||
eventsPublisher mocks.Publisher
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
)
|
||||
|
||||
JustBeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
|
||||
gatewayClient = &mocks.GatewayClient{}
|
||||
httpClient = &mocks.HTTPClient{}
|
||||
eventsPublisher = mocks.Publisher{}
|
||||
svc = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewayClient(gatewayClient),
|
||||
service.WithHTTPClient(httpClient),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("NewService", func() {
|
||||
It("returns a service", func() {
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("drive", func() {
|
||||
It("can list an empty list of spaces", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{},
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.GetDrives(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("can list a space without owner", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{
|
||||
{
|
||||
Id: &provider.StorageSpaceId{OpaqueId: "sameID"},
|
||||
SpaceType: "aspacetype",
|
||||
Root: &provider.ResourceId{
|
||||
StorageId: "sameID",
|
||||
OpaqueId: "sameID",
|
||||
},
|
||||
Name: "aspacename",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewNotFound(ctx, "not found"),
|
||||
}, nil)
|
||||
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
|
||||
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.GetDrives(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
body, _ := io.ReadAll(rr.Body)
|
||||
Expect(body).To(MatchJSON(`
|
||||
{
|
||||
"value":[
|
||||
{
|
||||
"driveType":"aspacetype",
|
||||
"id":"sameID",
|
||||
"name":"aspacename",
|
||||
"root":{
|
||||
"id":"sameID!sameID",
|
||||
"webDavUrl":"https://localhost:9200/dav/spaces/sameID"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
`))
|
||||
})
|
||||
It("can list a spaces with sort", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{
|
||||
{
|
||||
Id: &provider.StorageSpaceId{OpaqueId: "bsameID"},
|
||||
SpaceType: "bspacetype",
|
||||
Root: &provider.ResourceId{
|
||||
StorageId: "bsameID",
|
||||
OpaqueId: "bsameID",
|
||||
},
|
||||
Name: "bspacename",
|
||||
Opaque: &typesv1beta1.Opaque{
|
||||
Map: map[string]*typesv1beta1.OpaqueEntry{
|
||||
"spaceAlias": {Decoder: "plain", Value: []byte("bspacetype/bspacename")},
|
||||
"etag": {Decoder: "plain", Value: []byte("123456789")},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: &provider.StorageSpaceId{OpaqueId: "asameID"},
|
||||
SpaceType: "aspacetype",
|
||||
Root: &provider.ResourceId{
|
||||
StorageId: "asameID",
|
||||
OpaqueId: "asameID",
|
||||
},
|
||||
Name: "aspacename",
|
||||
Opaque: &typesv1beta1.Opaque{
|
||||
Map: map[string]*typesv1beta1.OpaqueEntry{
|
||||
"spaceAlias": {Decoder: "plain", Value: []byte("aspacetype/aspacename")},
|
||||
"etag": {Decoder: "plain", Value: []byte("101112131415")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewNotFound(ctx, "not found"),
|
||||
}, nil)
|
||||
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
|
||||
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives?$orderby=name%20asc", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.GetDrives(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
body, _ := io.ReadAll(rr.Body)
|
||||
Expect(body).To(MatchJSON(`
|
||||
{
|
||||
"value":[
|
||||
{
|
||||
"driveAlias":"aspacetype/aspacename",
|
||||
"driveType":"aspacetype",
|
||||
"id":"asameID",
|
||||
"name":"aspacename",
|
||||
"root":{
|
||||
"eTag":"101112131415",
|
||||
"id":"asameID!asameID",
|
||||
"webDavUrl":"https://localhost:9200/dav/spaces/asameID"
|
||||
}
|
||||
},
|
||||
{
|
||||
"driveAlias":"bspacetype/bspacename",
|
||||
"driveType":"bspacetype",
|
||||
"id":"bsameID",
|
||||
"name":"bspacename",
|
||||
"root":{
|
||||
"eTag":"123456789",
|
||||
"id":"bsameID!bsameID",
|
||||
"webDavUrl":"https://localhost:9200/dav/spaces/bsameID"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
`))
|
||||
})
|
||||
It("can list a spaces type mountpoint", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{
|
||||
{
|
||||
Id: &provider.StorageSpaceId{OpaqueId: "aID!differentID"},
|
||||
SpaceType: "mountpoint",
|
||||
Root: &provider.ResourceId{
|
||||
StorageId: "prID$aID",
|
||||
OpaqueId: "differentID",
|
||||
},
|
||||
Name: "New Folder",
|
||||
Opaque: &typesv1beta1.Opaque{
|
||||
Map: map[string]*typesv1beta1.OpaqueEntry{
|
||||
"spaceAlias": {Decoder: "plain", Value: []byte("mountpoint/new-folder")},
|
||||
"etag": {Decoder: "plain", Value: []byte("101112131415")},
|
||||
"grantStorageID": {Decoder: "plain", Value: []byte("ownerStorageID")},
|
||||
"grantOpaqueID": {Decoder: "plain", Value: []byte("opaqueID")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Info: &provider.ResourceInfo{
|
||||
Etag: "123456789",
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER,
|
||||
Id: &provider.ResourceId{StorageId: "ownerStorageID", OpaqueId: "opaqueID"},
|
||||
Path: "New Folder",
|
||||
Mtime: &typesv1beta1.Timestamp{Seconds: 1648327606, Nanos: 0},
|
||||
Size: uint64(1234),
|
||||
},
|
||||
}, nil)
|
||||
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
|
||||
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.GetDrives(rr, r)
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
|
||||
body, _ := io.ReadAll(rr.Body)
|
||||
|
||||
var response map[string][]libregraph.Drive
|
||||
err := json.Unmarshal(body, &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(response["value"])).To(Equal(1))
|
||||
value := response["value"][0]
|
||||
webdavURL, _ := url.PathUnescape(*value.Root.WebDavUrl)
|
||||
Expect(*value.DriveAlias).To(Equal("mountpoint/new-folder"))
|
||||
Expect(*value.DriveType).To(Equal("mountpoint"))
|
||||
Expect(*value.Id).To(Equal("prID$aID!differentID"))
|
||||
Expect(*value.Name).To(Equal("New Folder"))
|
||||
Expect(webdavURL).To(Equal("https://localhost:9200/dav/spaces/prID$aID!differentID"))
|
||||
Expect(*value.Root.ETag).To(Equal("101112131415"))
|
||||
Expect(*value.Root.Id).To(Equal("prID$aID!differentID"))
|
||||
Expect(*value.Root.RemoteItem.ETag).To(Equal("123456789"))
|
||||
Expect(*value.Root.RemoteItem.Id).To(Equal("ownerStorageID!opaqueID"))
|
||||
Expect(value.Root.RemoteItem.LastModifiedDateTime.UTC()).To(Equal(time.Unix(1648327606, 0).UTC()))
|
||||
Expect(*value.Root.RemoteItem.Folder).To(Equal(libregraph.Folder{}))
|
||||
Expect(*value.Root.RemoteItem.Name).To(Equal("New Folder"))
|
||||
Expect(*value.Root.RemoteItem.Size).To(Equal(int64(1234)))
|
||||
Expect(*value.Root.RemoteItem.WebDavUrl).To(Equal("https://localhost:9200/dav/spaces/ownerStorageID!opaqueID"))
|
||||
})
|
||||
It("can not list spaces with wrong sort parameter", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{}}, nil)
|
||||
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewNotFound(ctx, "not found"),
|
||||
}, nil)
|
||||
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
|
||||
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives?$orderby=owner%20asc", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.GetDrives(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
|
||||
body, _ := io.ReadAll(rr.Body)
|
||||
var libreError libregraph.OdataError
|
||||
err := json.Unmarshal(body, &libreError)
|
||||
Expect(err).To(Not(HaveOccurred()))
|
||||
Expect(libreError.Error.Message).To(Equal("we do not support <owner> as a order parameter"))
|
||||
Expect(libreError.Error.Code).To(Equal(errorcode.InvalidRequest.String()))
|
||||
})
|
||||
It("can list a spaces with invalid query parameter", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{}}, nil)
|
||||
gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Return(&gateway.InitiateFileDownloadResponse{
|
||||
Status: status.NewNotFound(ctx, "not found"),
|
||||
}, nil)
|
||||
gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{
|
||||
Status: status.NewUnimplemented(ctx, fmt.Errorf("not supported"), "not supported"),
|
||||
}, nil)
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drives?§orderby=owner%20asc", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.GetDrives(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
|
||||
body, _ := io.ReadAll(rr.Body)
|
||||
var libreError libregraph.OdataError
|
||||
err := json.Unmarshal(body, &libreError)
|
||||
Expect(err).To(Not(HaveOccurred()))
|
||||
Expect(libreError.Error.Message).To(Equal("Query parameter '§orderby' is not supported. Cause: Query parameter '§orderby' is not supported"))
|
||||
Expect(libreError.Error.Code).To(Equal(errorcode.InvalidRequest.String()))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,374 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
|
||||
ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/events"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
const memberRefsLimit = 20
|
||||
|
||||
// GetGroups implements the Service interface.
|
||||
func (g Graph) GetGroups(w http.ResponseWriter, r *http.Request) {
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := g.identityBackend.GetGroups(r.Context(), r.URL.Query())
|
||||
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
groups, err = sortGroups(odataReq, groups)
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &listResponse{Value: groups})
|
||||
}
|
||||
|
||||
// PostGroup implements the Service interface.
|
||||
func (g Graph) PostGroup(w http.ResponseWriter, r *http.Request) {
|
||||
grp := libregraph.NewGroup()
|
||||
err := json.NewDecoder(r.Body).Decode(grp)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := grp.GetDisplayNameOk(); !ok {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
|
||||
return
|
||||
}
|
||||
|
||||
// Disallow user-supplied IDs. It's supposed to be readonly. We're either
|
||||
// generating them in the backend ourselves or rely on the Backend's
|
||||
// storage (e.g. LDAP) to provide a unique ID.
|
||||
if _, ok := grp.GetIdOk(); ok {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "group id is a read-only attribute")
|
||||
return
|
||||
}
|
||||
|
||||
if grp, err = g.identityBackend.CreateGroup(r.Context(), *grp); err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if grp != nil && grp.Id != nil {
|
||||
currentUser := ctxpkg.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.GroupCreated{Executant: currentUser.Id, GroupID: *grp.Id})
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, grp)
|
||||
}
|
||||
|
||||
// PatchGroup implements the Service interface.
|
||||
func (g Graph) PatchGroup(w http.ResponseWriter, r *http.Request) {
|
||||
g.logger.Debug().Msg("Calling PatchGroup")
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
changes := libregraph.NewGroup()
|
||||
err = json.NewDecoder(r.Body).Decode(changes)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if memberRefs, ok := changes.GetMembersodataBindOk(); ok {
|
||||
// The spec defines a limit of 20 members maxium per Request
|
||||
if len(memberRefs) > memberRefsLimit {
|
||||
errorcode.NotAllowed.Render(w, r, http.StatusInternalServerError,
|
||||
fmt.Sprintf("Request is limited to %d members", memberRefsLimit))
|
||||
return
|
||||
}
|
||||
memberIDs := make([]string, 0, len(memberRefs))
|
||||
for _, memberRef := range memberRefs {
|
||||
memberType, id, err := g.parseMemberRef(memberRef)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "Error parsing member@odata.bind values")
|
||||
return
|
||||
}
|
||||
g.logger.Debug().Str("memberType", memberType).Str("memberid", id).Msg("Add Member")
|
||||
// The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact"
|
||||
// we restrict this to users for now. Might add Groups as members later
|
||||
if memberType != "users" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "Only user are allowed as group members")
|
||||
return
|
||||
}
|
||||
memberIDs = append(memberIDs, id)
|
||||
}
|
||||
err = g.identityBackend.AddMembersToGroup(r.Context(), groupID, memberIDs)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// GetGroup implements the Service interface.
|
||||
func (g Graph) GetGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
|
||||
group, err := g.identityBackend.GetGroup(r.Context(), groupID, r.URL.Query())
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, group)
|
||||
}
|
||||
|
||||
// DeleteGroup implements the Service interface.
|
||||
func (g Graph) DeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
|
||||
err = g.identityBackend.DeleteGroup(r.Context(), groupID)
|
||||
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := ctxpkg.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.GroupDeleted{Executant: currentUser.Id, GroupID: groupID})
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (g Graph) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
|
||||
members, err := g.identityBackend.GetGroupMembers(r.Context(), groupID)
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, members)
|
||||
}
|
||||
|
||||
// PostGroupMember implements the Service interface.
|
||||
func (g Graph) PostGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
g.logger.Info().Msg("Calling PostGroupMember")
|
||||
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
memberRef := libregraph.NewMemberReference()
|
||||
err = json.NewDecoder(r.Body).Decode(memberRef)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
memberRefURL, ok := memberRef.GetOdataIdOk()
|
||||
if !ok {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "@odata.id refernce is missing")
|
||||
return
|
||||
}
|
||||
memberType, id, err := g.parseMemberRef(*memberRefURL)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "Error parsing @odata.id url")
|
||||
return
|
||||
}
|
||||
// The MS Graph spec allows "directoryObject", "user", "group" and "organizational Contact"
|
||||
// we restrict this to users for now. Might add Groups as members later
|
||||
if memberType != "users" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "Only user are allowed as group members")
|
||||
return
|
||||
}
|
||||
|
||||
g.logger.Debug().Str("memberType", memberType).Str("id", id).Msg("Add Member")
|
||||
err = g.identityBackend.AddMembersToGroup(r.Context(), groupID, []string{id})
|
||||
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := ctxpkg.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.GroupMemberAdded{Executant: currentUser.Id, GroupID: groupID, UserID: id})
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// DeleteGroupMember implements the Service interface.
|
||||
func (g Graph) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
g.logger.Info().Msg("Calling DeleteGroupMember")
|
||||
|
||||
groupID := chi.URLParam(r, "groupID")
|
||||
groupID, err := url.PathUnescape(groupID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if groupID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
|
||||
memberID := chi.URLParam(r, "memberID")
|
||||
memberID, err = url.PathUnescape(memberID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping group id failed")
|
||||
return
|
||||
}
|
||||
|
||||
if memberID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing group id")
|
||||
return
|
||||
}
|
||||
g.logger.Debug().Str("groupID", groupID).Str("memberID", memberID).Msg("DeleteGroupMember")
|
||||
err = g.identityBackend.RemoveMemberFromGroup(r.Context(), groupID, memberID)
|
||||
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
currentUser := ctxpkg.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.GroupMemberRemoved{Executant: currentUser.Id, GroupID: groupID, UserID: memberID})
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
func (g Graph) parseMemberRef(ref string) (string, string, error) {
|
||||
memberURL, err := url.ParseRequestURI(ref)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
segments := strings.Split(memberURL.Path, "/")
|
||||
if len(segments) < 2 {
|
||||
return "", "", errors.New("invalid member reference")
|
||||
}
|
||||
id := segments[len(segments)-1]
|
||||
memberType := segments[len(segments)-2]
|
||||
return memberType, id, nil
|
||||
}
|
||||
|
||||
func sortGroups(req *godata.GoDataRequest, groups []*libregraph.Group) ([]*libregraph.Group, error) {
|
||||
var sorter sort.Interface
|
||||
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
|
||||
return groups, nil
|
||||
}
|
||||
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
|
||||
case "displayName":
|
||||
sorter = groupsByDisplayName{groups}
|
||||
default:
|
||||
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
|
||||
}
|
||||
|
||||
if req.Query.OrderBy.OrderByItems[0].Order == "desc" {
|
||||
sorter = sort.Reverse(sorter)
|
||||
}
|
||||
sort.Sort(sorter)
|
||||
return groups, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/metrics"
|
||||
)
|
||||
|
||||
// NewInstrument returns a service that instruments metrics.
|
||||
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
|
||||
return instrument{
|
||||
next: next,
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
type instrument struct {
|
||||
next Service
|
||||
metrics *metrics.Metrics
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (i instrument) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetMe implements the Service interface.
|
||||
func (i instrument) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.GetMe(w, r)
|
||||
}
|
||||
|
||||
// GetUsers implements the Service interface.
|
||||
func (i instrument) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.GetUsers(w, r)
|
||||
}
|
||||
|
||||
// GetUser implements the Service interface.
|
||||
func (i instrument) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.GetUser(w, r)
|
||||
}
|
||||
|
||||
// PostUser implements the Service interface.
|
||||
func (i instrument) PostUser(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.PostUser(w, r)
|
||||
}
|
||||
|
||||
// DeleteUser implements the Service interface.
|
||||
func (i instrument) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.DeleteUser(w, r)
|
||||
}
|
||||
|
||||
// PatchUser implements the Service interface.
|
||||
func (i instrument) PatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.PatchUser(w, r)
|
||||
}
|
||||
|
||||
// ChangeOwnPassword implements the Service interface.
|
||||
func (i instrument) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.ChangeOwnPassword(w, r)
|
||||
}
|
||||
|
||||
// GetGroups implements the Service interface.
|
||||
func (i instrument) GetGroups(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.GetGroups(w, r)
|
||||
}
|
||||
|
||||
// GetGroup implements the Service interface.
|
||||
func (i instrument) GetGroup(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.GetGroup(w, r)
|
||||
}
|
||||
|
||||
// PostGroup implements the Service interface.
|
||||
func (i instrument) PostGroup(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.PostGroup(w, r)
|
||||
}
|
||||
|
||||
// PatchGroup implements the Service interface.
|
||||
func (i instrument) PatchGroup(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.PatchGroup(w, r)
|
||||
}
|
||||
|
||||
// DeleteGroup implements the Service interface.
|
||||
func (i instrument) DeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.DeleteGroup(w, r)
|
||||
}
|
||||
|
||||
// GetGroupMembers implements the Service interface.
|
||||
func (i instrument) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.GetGroupMembers(w, r)
|
||||
}
|
||||
|
||||
// PostGroupMember implements the Service interface.
|
||||
func (i instrument) PostGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.PostGroupMember(w, r)
|
||||
}
|
||||
|
||||
// DeleteGroupMember implements the Service interface.
|
||||
func (i instrument) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.DeleteGroupMember(w, r)
|
||||
}
|
||||
|
||||
// GetDrives implements the Service interface.
|
||||
func (i instrument) GetDrives(w http.ResponseWriter, r *http.Request) {
|
||||
i.next.GetDrives(w, r)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
)
|
||||
|
||||
// NewLogging returns a service that logs messages.
|
||||
func NewLogging(next Service, logger log.Logger) Service {
|
||||
return logging{
|
||||
next: next,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type logging struct {
|
||||
next Service
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (l logging) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetMe implements the Service interface.
|
||||
func (l logging) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.GetMe(w, r)
|
||||
}
|
||||
|
||||
// GetUsers implements the Service interface.
|
||||
func (l logging) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.GetUsers(w, r)
|
||||
}
|
||||
|
||||
// GetUser implements the Service interface.
|
||||
func (l logging) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.GetUser(w, r)
|
||||
}
|
||||
|
||||
// PostUser implements the Service interface.
|
||||
func (l logging) PostUser(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.PostUser(w, r)
|
||||
}
|
||||
|
||||
// DeleteUser implements the Service interface.
|
||||
func (l logging) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.DeleteUser(w, r)
|
||||
}
|
||||
|
||||
// PatchUser implements the Service interface.
|
||||
func (l logging) PatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.PatchUser(w, r)
|
||||
}
|
||||
|
||||
// ChangeOwnPassword implements the Service interface.
|
||||
func (l logging) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.ChangeOwnPassword(w, r)
|
||||
}
|
||||
|
||||
// GetGroups implements the Service interface.
|
||||
func (l logging) GetGroups(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.GetGroups(w, r)
|
||||
}
|
||||
|
||||
// GetGroup implements the Service interface.
|
||||
func (l logging) GetGroup(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.GetGroup(w, r)
|
||||
}
|
||||
|
||||
// PostGroup implements the Service interface.
|
||||
func (l logging) PostGroup(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.PostGroup(w, r)
|
||||
}
|
||||
|
||||
// PatchGroup implements the Service interface.
|
||||
func (l logging) PatchGroup(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.PatchGroup(w, r)
|
||||
}
|
||||
|
||||
// DeleteGroup implements the Service interface.
|
||||
func (l logging) DeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.DeleteGroup(w, r)
|
||||
}
|
||||
|
||||
// GetGroupMembers implements the Service interface.
|
||||
func (l logging) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.GetGroupMembers(w, r)
|
||||
}
|
||||
|
||||
// PostGroupMember implements the Service interface.
|
||||
func (l logging) PostGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.PostGroupMember(w, r)
|
||||
}
|
||||
|
||||
// DeleteGroupMember implements the Service interface.
|
||||
func (l logging) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.DeleteGroupMember(w, r)
|
||||
}
|
||||
|
||||
// GetDrives implements the Service interface.
|
||||
func (l logging) GetDrives(w http.ResponseWriter, r *http.Request) {
|
||||
l.next.GetDrives(w, r)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package net
|
||||
|
||||
const (
|
||||
// "github.com/cs3org/reva/v2/internal/http/services/datagateway" is internal so we redeclare it here
|
||||
// HeaderTokenTransport holds the header key for the reva transfer token
|
||||
HeaderTokenTransport = "X-Reva-Transfer"
|
||||
// HeaderIfModifiedSince is used to mimic/pass on caching headers when using grpc
|
||||
HeaderIfModifiedSince = "If-Modified-Since"
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cs3org/reva/v2/pkg/events"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/roles"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/identity"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
type Option func(o *Options)
|
||||
|
||||
// Options defines the available options for this package.
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
GatewayClient GatewayClient
|
||||
IdentityBackend identity.Backend
|
||||
HTTPClient HTTPClient
|
||||
RoleService settingssvc.RoleService
|
||||
RoleManager *roles.Manager
|
||||
EventsPublisher events.Publisher
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
func newOptions(opts ...Option) Options {
|
||||
opt := Options{}
|
||||
|
||||
for _, o := range opts {
|
||||
o(&opt)
|
||||
}
|
||||
|
||||
return opt
|
||||
}
|
||||
|
||||
// Logger provides a function to set the logger option.
|
||||
func Logger(val log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = val
|
||||
}
|
||||
}
|
||||
|
||||
// Config provides a function to set the config option.
|
||||
func Config(val *config.Config) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = val
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware provides a function to set the middleware option.
|
||||
func Middleware(val ...func(http.Handler) http.Handler) Option {
|
||||
return func(o *Options) {
|
||||
o.Middleware = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithGatewayClient provides a function to set the gateway client option.
|
||||
func WithGatewayClient(val GatewayClient) Option {
|
||||
return func(o *Options) {
|
||||
o.GatewayClient = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithIdentityBackend provides a function to set the IdentityBackend option.
|
||||
func WithIdentityBackend(val identity.Backend) Option {
|
||||
return func(o *Options) {
|
||||
o.IdentityBackend = val
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient provides a function to set the http client option.
|
||||
func WithHTTPClient(val HTTPClient) Option {
|
||||
return func(o *Options) {
|
||||
o.HTTPClient = val
|
||||
}
|
||||
}
|
||||
|
||||
// RoleService provides a function to set the RoleService option.
|
||||
func RoleService(val settingssvc.RoleService) Option {
|
||||
return func(o *Options) {
|
||||
o.RoleService = val
|
||||
}
|
||||
}
|
||||
|
||||
// RoleManager provides a function to set the RoleManager option.
|
||||
func RoleManager(val *roles.Manager) Option {
|
||||
return func(o *Options) {
|
||||
o.RoleManager = val
|
||||
}
|
||||
}
|
||||
|
||||
// EventsPublisher provides a function to set the EventsPublisher option.
|
||||
func EventsPublisher(val events.Publisher) Option {
|
||||
return func(o *Options) {
|
||||
o.EventsPublisher = val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
)
|
||||
|
||||
type spacesSlice []*libregraph.Drive
|
||||
|
||||
// Len is the number of elements in the collection.
|
||||
func (d spacesSlice) Len() int { return len(d) }
|
||||
|
||||
// Swap swaps the elements with indexes i and j.
|
||||
func (d spacesSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
|
||||
|
||||
type spacesByName struct {
|
||||
spacesSlice
|
||||
}
|
||||
type spacesByLastModifiedDateTime struct {
|
||||
spacesSlice
|
||||
}
|
||||
|
||||
// Less reports whether the element with index i
|
||||
// must sort before the element with index j.
|
||||
func (s spacesByName) Less(i, j int) bool {
|
||||
return strings.ToLower(*s.spacesSlice[i].Name) < strings.ToLower(*s.spacesSlice[j].Name)
|
||||
}
|
||||
|
||||
// Less reports whether the element with index i
|
||||
// must sort before the element with index j.
|
||||
func (s spacesByLastModifiedDateTime) Less(i, j int) bool {
|
||||
// compare the items when both dates are set
|
||||
if s.spacesSlice[i].LastModifiedDateTime != nil && s.spacesSlice[j].LastModifiedDateTime != nil {
|
||||
return s.spacesSlice[i].LastModifiedDateTime.Before(*s.spacesSlice[j].LastModifiedDateTime)
|
||||
}
|
||||
// an item without a timestamp is considered "less than" an item with a timestamp
|
||||
if s.spacesSlice[i].LastModifiedDateTime == nil && s.spacesSlice[j].LastModifiedDateTime != nil {
|
||||
return true
|
||||
}
|
||||
// an item without a timestamp is considered "less than" an item with a timestamp
|
||||
if s.spacesSlice[i].LastModifiedDateTime != nil && s.spacesSlice[j].LastModifiedDateTime == nil {
|
||||
return false
|
||||
}
|
||||
// fallback to name if no dateTime is set on both items
|
||||
return strings.ToLower(*s.spacesSlice[i].Name) < strings.ToLower(*s.spacesSlice[j].Name)
|
||||
}
|
||||
|
||||
type userSlice []*libregraph.User
|
||||
|
||||
// Len is the number of elements in the collection.
|
||||
func (d userSlice) Len() int { return len(d) }
|
||||
|
||||
// Swap swaps the elements with indexes i and j.
|
||||
func (d userSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
|
||||
|
||||
type usersByDisplayName struct {
|
||||
userSlice
|
||||
}
|
||||
|
||||
type usersByMail struct {
|
||||
userSlice
|
||||
}
|
||||
|
||||
type usersByOnPremisesSamAccountName struct {
|
||||
userSlice
|
||||
}
|
||||
|
||||
// Less reports whether the element with index i
|
||||
// must sort before the element with index j.
|
||||
func (u usersByDisplayName) Less(i, j int) bool {
|
||||
return strings.ToLower(u.userSlice[i].GetDisplayName()) < strings.ToLower(u.userSlice[j].GetDisplayName())
|
||||
}
|
||||
|
||||
// Less reports whether the element with index i
|
||||
// must sort before the element with index j.
|
||||
func (u usersByMail) Less(i, j int) bool {
|
||||
return strings.ToLower(u.userSlice[i].GetMail()) < strings.ToLower(u.userSlice[j].GetMail())
|
||||
}
|
||||
|
||||
// Less reports whether the element with index i
|
||||
// must sort before the element with index j.
|
||||
func (u usersByOnPremisesSamAccountName) Less(i, j int) bool {
|
||||
return strings.ToLower(u.userSlice[i].GetOnPremisesSamAccountName()) < strings.ToLower(u.userSlice[j].GetOnPremisesSamAccountName())
|
||||
}
|
||||
|
||||
type groupSlice []*libregraph.Group
|
||||
|
||||
// Len is the number of elements in the collection.
|
||||
func (d groupSlice) Len() int { return len(d) }
|
||||
|
||||
// Swap swaps the elements with indexes i and j.
|
||||
func (d groupSlice) Swap(i, j int) { d[i], d[j] = d[j], d[i] }
|
||||
|
||||
type groupsByDisplayName struct {
|
||||
groupSlice
|
||||
}
|
||||
|
||||
// Less reports whether the element with index i
|
||||
// must sort before the element with index j.
|
||||
func (g groupsByDisplayName) Less(i, j int) bool {
|
||||
return strings.ToLower(g.groupSlice[i].GetDisplayName()) < strings.ToLower(g.groupSlice[j].GetDisplayName())
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/events"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
)
|
||||
|
||||
// ChangeOwnPassword implements the Service interface. It allows the user to change
|
||||
// its own password
|
||||
func (g Graph) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
u, ok := revactx.ContextGetUser(ctx)
|
||||
if !ok {
|
||||
g.logger.Error().Msg("user not in context")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "user not in context")
|
||||
return
|
||||
}
|
||||
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
_, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
cpw := libregraph.NewPasswordChange()
|
||||
err = json.NewDecoder(r.Body).Decode(cpw)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
currentPw := cpw.GetCurrentPassword()
|
||||
if currentPw == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "current password cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
newPw := cpw.GetNewPassword()
|
||||
if newPw == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
if newPw == currentPw {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password must be differnt from current password")
|
||||
return
|
||||
}
|
||||
|
||||
authReq := &gateway.AuthenticateRequest{
|
||||
Type: "basic",
|
||||
ClientId: u.Username,
|
||||
ClientSecret: currentPw,
|
||||
}
|
||||
authRes, err := g.gatewayClient.Authenticate(r.Context(), authReq)
|
||||
if err != nil {
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if authRes.Status.Code != cs3rpc.Code_CODE_OK {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
|
||||
return
|
||||
}
|
||||
|
||||
newPwProfile := libregraph.NewPasswordProfile()
|
||||
newPwProfile.SetPassword(newPw)
|
||||
changes := libregraph.NewUser()
|
||||
changes.SetPasswordProfile(*newPwProfile)
|
||||
_, err = g.identityBackend.UpdateUser(ctx, u.Id.OpaqueId, *changes)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
|
||||
g.logger.Debug().Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to update user password")
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := ctxpkg.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(
|
||||
events.UserFeatureChanged{
|
||||
Executant: currentUser.Id,
|
||||
UserID: u.Id.OpaqueId,
|
||||
Features: []events.UserFeature{
|
||||
{Name: "password", Value: "***"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package svc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/status"
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/log"
|
||||
"github.com/owncloud/ocis/v2/services/graph/mocks"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config/defaults"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/identity"
|
||||
service "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type changePwTest struct {
|
||||
desc string
|
||||
currentpw string
|
||||
newpw string
|
||||
expected int
|
||||
}
|
||||
|
||||
var _ = Describe("Users changing their own password", func() {
|
||||
var (
|
||||
svc service.Service
|
||||
gatewayClient *mocks.GatewayClient
|
||||
httpClient *mocks.HTTPClient
|
||||
ldapClient *mocks.Client
|
||||
ldapConfig config.LDAP
|
||||
identityBackend identity.Backend
|
||||
eventsPublisher mocks.Publisher
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
user *userv1beta1.User
|
||||
err error
|
||||
)
|
||||
|
||||
JustBeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
cfg = defaults.FullDefaultConfig()
|
||||
cfg.TokenManager.JWTSecret = "loremipsum"
|
||||
|
||||
gatewayClient = &mocks.GatewayClient{}
|
||||
ldapClient = mockedLDAPClient()
|
||||
|
||||
ldapConfig = config.LDAP{
|
||||
WriteEnabled: true,
|
||||
UserDisplayNameAttribute: "displayName",
|
||||
UserNameAttribute: "uid",
|
||||
UserEmailAttribute: "mail",
|
||||
UserIDAttribute: "ownclouduuid",
|
||||
UserSearchScope: "sub",
|
||||
GroupNameAttribute: "cn",
|
||||
GroupIDAttribute: "ownclouduui",
|
||||
GroupSearchScope: "sub",
|
||||
}
|
||||
loggger := log.NewLogger()
|
||||
identityBackend, err = identity.NewLDAPBackend(ldapClient, ldapConfig, &loggger)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
httpClient = &mocks.HTTPClient{}
|
||||
eventsPublisher = mocks.Publisher{}
|
||||
svc = service.NewService(
|
||||
service.Config(cfg),
|
||||
service.WithGatewayClient(gatewayClient),
|
||||
service.WithIdentityBackend(identityBackend),
|
||||
service.WithHTTPClient(httpClient),
|
||||
service.EventsPublisher(&eventsPublisher),
|
||||
)
|
||||
user = &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
OpaqueId: "user",
|
||||
},
|
||||
}
|
||||
ctx = revactx.ContextSetUser(ctx, user)
|
||||
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
})
|
||||
|
||||
It("fails if no user in context", func() {
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/changePassword", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.ChangeOwnPassword(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
|
||||
DescribeTable("changing the password",
|
||||
func(current string, newpw string, authresult string, expected int) {
|
||||
switch authresult {
|
||||
case "error":
|
||||
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(nil, errors.New("fail"))
|
||||
case "deny":
|
||||
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
|
||||
Status: status.NewPermissionDenied(ctx, errors.New("wrong password"), "wrong password"),
|
||||
Token: "authtoken",
|
||||
}, nil)
|
||||
default:
|
||||
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Token: "authtoken",
|
||||
}, nil)
|
||||
}
|
||||
cpw := libregraph.NewPasswordChange()
|
||||
cpw.SetCurrentPassword(current)
|
||||
cpw.SetNewPassword(newpw)
|
||||
body, _ := json.Marshal(cpw)
|
||||
b := bytes.NewBuffer(body)
|
||||
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/me/changePassword", b).WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
svc.ChangeOwnPassword(rr, r)
|
||||
Expect(rr.Code).To(Equal(expected))
|
||||
},
|
||||
Entry("fails when current password is empty", "", "newpassword", "", http.StatusBadRequest),
|
||||
Entry("fails when new password is empty", "currentpassword", "", "", http.StatusBadRequest),
|
||||
Entry("fails when current and new password are equal", "password", "password", "", http.StatusBadRequest),
|
||||
Entry("fails authentication with current password errors", "currentpassword", "newpassword", "error", http.StatusInternalServerError),
|
||||
Entry("fails when current password is wrong", "currentpassword", "newpassword", "deny", http.StatusInternalServerError),
|
||||
Entry("succeeds when current password is correct", "currentpassword", "newpassword", "", http.StatusNoContent),
|
||||
)
|
||||
})
|
||||
|
||||
func mockedLDAPClient() *mocks.Client {
|
||||
lm := &mocks.Client{}
|
||||
|
||||
userEntry := ldap.NewEntry("uid=test", map[string][]string{
|
||||
"uid": {"test"},
|
||||
"displayName": {"test"},
|
||||
"mail": {"test@example.org"},
|
||||
})
|
||||
|
||||
lm.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything,
|
||||
mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(
|
||||
&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}},
|
||||
nil)
|
||||
|
||||
mr := ldap.NewModifyRequest("uid=test", nil)
|
||||
mr.Changes = []ldap.Change{
|
||||
{
|
||||
Operation: ldap.ReplaceAttribute,
|
||||
Modification: ldap.PartialAttribute{
|
||||
Type: "userPassword",
|
||||
Vals: []string{"newpassword"},
|
||||
},
|
||||
},
|
||||
}
|
||||
lm.On("Modify", mr).Return(nil)
|
||||
return lm
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ReneKroon/ttlcache/v2"
|
||||
"github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
ocisldap "github.com/owncloud/ocis/v2/ocis-pkg/ldap"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/roles"
|
||||
"github.com/owncloud/ocis/v2/ocis-pkg/service/grpc"
|
||||
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/identity"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/identity/ldap"
|
||||
graphm "github.com/owncloud/ocis/v2/services/graph/pkg/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
// HeaderPurge defines the header name for the purge header.
|
||||
HeaderPurge = "Purge"
|
||||
)
|
||||
|
||||
// Service defines the extension handlers.
|
||||
type Service interface {
|
||||
ServeHTTP(http.ResponseWriter, *http.Request)
|
||||
GetMe(http.ResponseWriter, *http.Request)
|
||||
GetUsers(http.ResponseWriter, *http.Request)
|
||||
GetUser(http.ResponseWriter, *http.Request)
|
||||
PostUser(http.ResponseWriter, *http.Request)
|
||||
DeleteUser(http.ResponseWriter, *http.Request)
|
||||
PatchUser(http.ResponseWriter, *http.Request)
|
||||
ChangeOwnPassword(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetGroups(http.ResponseWriter, *http.Request)
|
||||
GetGroup(http.ResponseWriter, *http.Request)
|
||||
PostGroup(http.ResponseWriter, *http.Request)
|
||||
PatchGroup(http.ResponseWriter, *http.Request)
|
||||
DeleteGroup(http.ResponseWriter, *http.Request)
|
||||
GetGroupMembers(http.ResponseWriter, *http.Request)
|
||||
PostGroupMember(http.ResponseWriter, *http.Request)
|
||||
DeleteGroupMember(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetDrives(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// NewService returns a service implementation for Service.
|
||||
func NewService(opts ...Option) Service {
|
||||
options := newOptions(opts...)
|
||||
|
||||
m := chi.NewMux()
|
||||
m.Use(options.Middleware...)
|
||||
|
||||
svc := Graph{
|
||||
config: options.Config,
|
||||
mux: m,
|
||||
logger: &options.Logger,
|
||||
spacePropertiesCache: ttlcache.NewCache(),
|
||||
eventsPublisher: options.EventsPublisher,
|
||||
}
|
||||
if options.GatewayClient == nil {
|
||||
var err error
|
||||
svc.gatewayClient, err = pool.GetGatewayServiceClient(options.Config.Reva.Address)
|
||||
if err != nil {
|
||||
options.Logger.Error().Err(err).Msg("Could not get gateway client")
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
svc.gatewayClient = options.GatewayClient
|
||||
}
|
||||
if options.IdentityBackend == nil {
|
||||
switch options.Config.Identity.Backend {
|
||||
case "cs3":
|
||||
svc.identityBackend = &identity.CS3{
|
||||
Config: options.Config.Reva,
|
||||
Logger: &options.Logger,
|
||||
}
|
||||
case "ldap":
|
||||
var err error
|
||||
|
||||
var tlsConf *tls.Config
|
||||
if options.Config.Identity.LDAP.Insecure {
|
||||
// When insecure is set to true then we don't need a certificate.
|
||||
options.Config.Identity.LDAP.CACert = ""
|
||||
tlsConf = &tls.Config{
|
||||
//nolint:gosec // We need the ability to run with "insecure" (dev/testing)
|
||||
InsecureSkipVerify: options.Config.Identity.LDAP.Insecure,
|
||||
}
|
||||
}
|
||||
|
||||
if options.Config.Identity.LDAP.CACert != "" {
|
||||
if err := ocisldap.WaitForCA(options.Logger,
|
||||
options.Config.Identity.LDAP.Insecure,
|
||||
options.Config.Identity.LDAP.CACert); err != nil {
|
||||
options.Logger.Fatal().Err(err).Msg("The configured LDAP CA cert does not exist")
|
||||
}
|
||||
if tlsConf == nil {
|
||||
tlsConf = &tls.Config{}
|
||||
}
|
||||
certs := x509.NewCertPool()
|
||||
pemData, err := ioutil.ReadFile(options.Config.Identity.LDAP.CACert)
|
||||
if err != nil {
|
||||
options.Logger.Error().Err(err).Msgf("Error initializing LDAP Backend")
|
||||
return nil
|
||||
}
|
||||
if !certs.AppendCertsFromPEM(pemData) {
|
||||
options.Logger.Error().Msgf("Error initializing LDAP Backend. Adding CA cert failed")
|
||||
return nil
|
||||
}
|
||||
tlsConf.RootCAs = certs
|
||||
}
|
||||
|
||||
conn := ldap.NewLDAPWithReconnect(&options.Logger,
|
||||
ldap.Config{
|
||||
URI: options.Config.Identity.LDAP.URI,
|
||||
BindDN: options.Config.Identity.LDAP.BindDN,
|
||||
BindPassword: options.Config.Identity.LDAP.BindPassword,
|
||||
TLSConfig: tlsConf,
|
||||
},
|
||||
)
|
||||
if svc.identityBackend, err = identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger); err != nil {
|
||||
options.Logger.Error().Msgf("Error initializing LDAP Backend: '%s'", err)
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
options.Logger.Error().Msgf("Unknown Identity Backend: '%s'", options.Config.Identity.Backend)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
svc.identityBackend = options.IdentityBackend
|
||||
}
|
||||
|
||||
if options.HTTPClient == nil {
|
||||
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: options.Config.Spaces.Insecure, //nolint:gosec
|
||||
}
|
||||
svc.httpClient = &http.Client{}
|
||||
} else {
|
||||
svc.httpClient = options.HTTPClient
|
||||
}
|
||||
|
||||
if options.RoleService == nil {
|
||||
svc.roleService = settingssvc.NewRoleService("com.owncloud.api.settings", grpc.DefaultClient)
|
||||
} else {
|
||||
svc.roleService = options.RoleService
|
||||
}
|
||||
|
||||
roleManager := options.RoleManager
|
||||
if roleManager == nil {
|
||||
m := roles.NewManager(
|
||||
roles.CacheSize(1024),
|
||||
roles.CacheTTL(time.Hour),
|
||||
roles.Logger(options.Logger),
|
||||
roles.RoleService(svc.roleService),
|
||||
)
|
||||
roleManager = &m
|
||||
}
|
||||
|
||||
requireAdmin := graphm.RequireAdmin(roleManager, options.Logger)
|
||||
|
||||
m.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Use(middleware.StripSlashes)
|
||||
r.Route("/v1.0", func(r chi.Router) {
|
||||
r.Route("/me", func(r chi.Router) {
|
||||
r.Get("/", svc.GetMe)
|
||||
r.Get("/drives", svc.GetDrives)
|
||||
r.Get("/drive/root/children", svc.GetRootDriveChildren)
|
||||
r.Post("/changePassword", svc.ChangeOwnPassword)
|
||||
})
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
r.With(requireAdmin).Get("/", svc.GetUsers)
|
||||
r.With(requireAdmin).Post("/", svc.PostUser)
|
||||
r.Route("/{userID}", func(r chi.Router) {
|
||||
r.Get("/", svc.GetUser)
|
||||
r.With(requireAdmin).Delete("/", svc.DeleteUser)
|
||||
r.With(requireAdmin).Patch("/", svc.PatchUser)
|
||||
})
|
||||
})
|
||||
r.Route("/groups", func(r chi.Router) {
|
||||
r.With(requireAdmin).Get("/", svc.GetGroups)
|
||||
r.With(requireAdmin).Post("/", svc.PostGroup)
|
||||
r.Route("/{groupID}", func(r chi.Router) {
|
||||
r.Get("/", svc.GetGroup)
|
||||
r.With(requireAdmin).Delete("/", svc.DeleteGroup)
|
||||
r.With(requireAdmin).Patch("/", svc.PatchGroup)
|
||||
r.Route("/members", func(r chi.Router) {
|
||||
r.With(requireAdmin).Get("/", svc.GetGroupMembers)
|
||||
r.With(requireAdmin).Post("/$ref", svc.PostGroupMember)
|
||||
r.With(requireAdmin).Delete("/{memberID}/$ref", svc.DeleteGroupMember)
|
||||
})
|
||||
})
|
||||
})
|
||||
r.Route("/drives", func(r chi.Router) {
|
||||
r.Get("/", svc.GetAllDrives)
|
||||
r.Post("/", svc.CreateDrive)
|
||||
r.Route("/{driveID}", func(r chi.Router) {
|
||||
r.Patch("/", svc.UpdateDrive)
|
||||
r.Get("/", svc.GetSingleDrive)
|
||||
r.Delete("/", svc.DeleteDrive)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// parseHeaderPurge parses the 'Purge' header.
|
||||
// '1', 't', 'T', 'TRUE', 'true', 'True' are parsed as true
|
||||
// all other values are false.
|
||||
func parsePurgeHeader(h http.Header) bool {
|
||||
val := h.Get(HeaderPurge)
|
||||
|
||||
if b, err := strconv.ParseBool(val); err == nil {
|
||||
return b
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePurgeHeader(t *testing.T) {
|
||||
tests := map[string]bool{
|
||||
"": false,
|
||||
"f": false,
|
||||
"F": false,
|
||||
"anything": false,
|
||||
"t": true,
|
||||
"T": true,
|
||||
}
|
||||
|
||||
for input, expected := range tests {
|
||||
h := make(http.Header)
|
||||
h.Add(HeaderPurge, input)
|
||||
|
||||
if expected != parsePurgeHeader(h) {
|
||||
t.Errorf("parsePurgeHeader with input %s got %t expected %t", input, !expected, expected)
|
||||
}
|
||||
}
|
||||
|
||||
if h := make(http.Header); parsePurgeHeader(h) {
|
||||
t.Error("parsePurgeHeader without Purge header set got true expected false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// NewTracing returns a service that instruments traces.
|
||||
func NewTracing(next Service) Service {
|
||||
return tracing{
|
||||
next: next,
|
||||
}
|
||||
}
|
||||
|
||||
type tracing struct {
|
||||
next Service
|
||||
}
|
||||
|
||||
// ServeHTTP implements the Service interface.
|
||||
func (t tracing) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// GetMe implements the Service interface.
|
||||
func (t tracing) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.GetMe(w, r)
|
||||
}
|
||||
|
||||
// GetUsers implements the Service interface.
|
||||
func (t tracing) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.GetUsers(w, r)
|
||||
}
|
||||
|
||||
// GetUser implements the Service interface.
|
||||
func (t tracing) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.GetUser(w, r)
|
||||
}
|
||||
|
||||
// PostUser implements the Service interface.
|
||||
func (t tracing) PostUser(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.PostUser(w, r)
|
||||
}
|
||||
|
||||
// DeleteUser implements the Service interface.
|
||||
func (t tracing) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.DeleteUser(w, r)
|
||||
}
|
||||
|
||||
// PatchUser implements the Service interface.
|
||||
func (t tracing) PatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.PatchUser(w, r)
|
||||
}
|
||||
|
||||
// ChangeOwnPassword implements the Service interface.
|
||||
func (t tracing) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.ChangeOwnPassword(w, r)
|
||||
}
|
||||
|
||||
// GetGroups implements the Service interface.
|
||||
func (t tracing) GetGroups(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.GetGroups(w, r)
|
||||
}
|
||||
|
||||
// GetGroup implements the Service interface.
|
||||
func (t tracing) GetGroup(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.GetGroup(w, r)
|
||||
}
|
||||
|
||||
// PostGroup implements the Service interface.
|
||||
func (t tracing) PostGroup(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.PostGroup(w, r)
|
||||
}
|
||||
|
||||
// PatchGroup implements the Service interface.
|
||||
func (t tracing) PatchGroup(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.PatchGroup(w, r)
|
||||
}
|
||||
|
||||
// DeleteGroup implements the Service interface.
|
||||
func (t tracing) DeleteGroup(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.DeleteGroup(w, r)
|
||||
}
|
||||
|
||||
// GetGroupMembers implements the Service interface.
|
||||
func (t tracing) GetGroupMembers(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.GetGroupMembers(w, r)
|
||||
}
|
||||
|
||||
// PostGroupMember implements the Service interface.
|
||||
func (t tracing) PostGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.PostGroupMember(w, r)
|
||||
}
|
||||
|
||||
// DeleteGroupMember implements the Service interface.
|
||||
func (t tracing) DeleteGroupMember(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.DeleteGroupMember(w, r)
|
||||
}
|
||||
|
||||
// GetDrives implements the Service interface.
|
||||
func (t tracing) GetDrives(w http.ResponseWriter, r *http.Request) {
|
||||
t.next.GetDrives(w, r)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/CiscoM31/godata"
|
||||
ctxpkg "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
revactx "github.com/cs3org/reva/v2/pkg/ctx"
|
||||
"github.com/cs3org/reva/v2/pkg/events"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/owncloud/libre-graph-api-go"
|
||||
settings "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/identity"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode"
|
||||
settingssvc "github.com/owncloud/ocis/v2/services/settings/pkg/service/v0"
|
||||
)
|
||||
|
||||
// GetMe implements the Service interface.
|
||||
func (g Graph) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
u, ok := revactx.ContextGetUser(r.Context())
|
||||
if !ok {
|
||||
g.logger.Error().Msg("user not in context")
|
||||
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "user not in context")
|
||||
return
|
||||
}
|
||||
|
||||
g.logger.Info().Interface("user", u).Msg("User in /me")
|
||||
|
||||
me := identity.CreateUserModelFromCS3(u)
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, me)
|
||||
}
|
||||
|
||||
// GetUsers implements the Service interface.
|
||||
func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
|
||||
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
|
||||
if err != nil {
|
||||
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
users, err := g.identityBackend.GetUsers(r.Context(), r.URL.Query())
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
users, err = sortUsers(odataReq, users)
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &listResponse{Value: users})
|
||||
}
|
||||
|
||||
func (g Graph) PostUser(w http.ResponseWriter, r *http.Request) {
|
||||
u := libregraph.NewUser()
|
||||
err := json.NewDecoder(r.Body).Decode(u)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := u.GetDisplayNameOk(); !ok {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'displayName'")
|
||||
return
|
||||
}
|
||||
if accountName, ok := u.GetOnPremisesSamAccountNameOk(); ok {
|
||||
if !isValidUsername(*accountName) {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
|
||||
fmt.Sprintf("username '%s' must be at least the local part of an email", *u.OnPremisesSamAccountName))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'onPremisesSamAccountName'")
|
||||
return
|
||||
}
|
||||
|
||||
if mail, ok := u.GetMailOk(); ok {
|
||||
if !isValidEmail(*mail) {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
|
||||
fmt.Sprintf("'%s' is not a valid email address", *u.Mail))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'mail'")
|
||||
return
|
||||
}
|
||||
|
||||
// Disallow user-supplied IDs. It's supposed to be readonly. We're either
|
||||
// generating them in the backend ourselves or rely on the Backend's
|
||||
// storage (e.g. LDAP) to provide a unique ID.
|
||||
if _, ok := u.GetIdOk(); ok {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "user id is a read-only attribute")
|
||||
return
|
||||
}
|
||||
|
||||
if u, err = g.identityBackend.CreateUser(r.Context(), *u); err != nil {
|
||||
var ecErr errorcode.Error
|
||||
if errors.As(err, &ecErr) {
|
||||
ecErr.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// All users get the user role by default currently.
|
||||
// to all new users for now, as create Account request does not have any role field
|
||||
if g.roleService == nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not assign role to account: roleService not configured")
|
||||
return
|
||||
}
|
||||
if _, err = g.roleService.AssignRoleToUser(r.Context(), &settings.AssignRoleToUserRequest{
|
||||
AccountUuid: *u.Id,
|
||||
RoleId: settingssvc.BundleUUIDRoleUser,
|
||||
}); err != nil {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, fmt.Sprintf("could not assign role to account %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := ctxpkg.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.UserCreated{Executant: currentUser.Id, UserID: *u.Id})
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, u)
|
||||
}
|
||||
|
||||
// GetUser implements the Service interface.
|
||||
func (g Graph) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userID")
|
||||
userID, err := url.PathUnescape(userID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := g.identityBackend.GetUser(r.Context(), userID, r.URL.Query())
|
||||
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, user)
|
||||
}
|
||||
|
||||
func (g Graph) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userID")
|
||||
userID, err := url.PathUnescape(userID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
|
||||
return
|
||||
}
|
||||
|
||||
err = g.identityBackend.DeleteUser(r.Context(), userID)
|
||||
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
currentUser := ctxpkg.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(events.UserDeleted{Executant: currentUser.Id, UserID: userID})
|
||||
|
||||
render.Status(r, http.StatusNoContent)
|
||||
render.NoContent(w, r)
|
||||
}
|
||||
|
||||
// PatchUser implements the Service Interface. Updates the specified attributes of an
|
||||
// ExistingUser
|
||||
func (g Graph) PatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
nameOrID := chi.URLParam(r, "userID")
|
||||
nameOrID, err := url.PathUnescape(nameOrID)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping user id failed")
|
||||
}
|
||||
|
||||
if nameOrID == "" {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing user id")
|
||||
return
|
||||
}
|
||||
changes := libregraph.NewUser()
|
||||
err = json.NewDecoder(r.Body).Decode(changes)
|
||||
if err != nil {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var features []events.UserFeature
|
||||
if mail, ok := changes.GetMailOk(); ok {
|
||||
if !isValidEmail(*mail) {
|
||||
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
|
||||
fmt.Sprintf("'%s' is not a valid email address", *mail))
|
||||
return
|
||||
}
|
||||
features = append(features, events.UserFeature{Name: "email", Value: *mail})
|
||||
}
|
||||
|
||||
if name, ok := changes.GetDisplayNameOk(); ok {
|
||||
features = append(features, events.UserFeature{Name: "displayname", Value: *name})
|
||||
}
|
||||
|
||||
u, err := g.identityBackend.UpdateUser(r.Context(), nameOrID, *changes)
|
||||
if err != nil {
|
||||
var errcode errorcode.Error
|
||||
if errors.As(err, &errcode) {
|
||||
errcode.Render(w, r)
|
||||
} else {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
currentUser := ctxpkg.ContextMustGetUser(r.Context())
|
||||
g.publishEvent(
|
||||
events.UserFeatureChanged{
|
||||
Executant: currentUser.Id,
|
||||
UserID: nameOrID,
|
||||
Features: features,
|
||||
},
|
||||
)
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, u)
|
||||
|
||||
}
|
||||
|
||||
// We want to allow email addresses as usernames so they show up when using them in ACLs on storages that allow integration with our glauth LDAP service
|
||||
// so we are adding a few restrictions from https://stackoverflow.com/questions/6949667/what-are-the-real-rules-for-linux-usernames-on-centos-6-and-rhel-6
|
||||
// names should not start with numbers
|
||||
var usernameRegex = regexp.MustCompile("^[a-zA-Z_][a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]*(@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)*$")
|
||||
|
||||
func isValidUsername(e string) bool {
|
||||
if len(e) < 1 && len(e) > 254 {
|
||||
return false
|
||||
}
|
||||
return usernameRegex.MatchString(e)
|
||||
}
|
||||
|
||||
// regex from https://www.w3.org/TR/2016/REC-html51-20161101/sec-forms.html#valid-e-mail-address
|
||||
var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
||||
|
||||
func isValidEmail(e string) bool {
|
||||
if len(e) < 3 && len(e) > 254 {
|
||||
return false
|
||||
}
|
||||
return emailRegex.MatchString(e)
|
||||
}
|
||||
|
||||
func sortUsers(req *godata.GoDataRequest, users []*libregraph.User) ([]*libregraph.User, error) {
|
||||
var sorter sort.Interface
|
||||
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
|
||||
return users, nil
|
||||
}
|
||||
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
|
||||
case "displayName":
|
||||
sorter = usersByDisplayName{users}
|
||||
case "mail":
|
||||
sorter = usersByMail{users}
|
||||
case "onPremisesSamAccountName":
|
||||
sorter = usersByOnPremisesSamAccountName{users}
|
||||
default:
|
||||
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
|
||||
}
|
||||
|
||||
if req.Query.OrderBy.OrderByItems[0].Order == "desc" {
|
||||
sorter = sort.Reverse(sorter)
|
||||
}
|
||||
sort.Sort(sorter)
|
||||
return users, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package tracing
|
||||
|
||||
import (
|
||||
pkgtrace "github.com/owncloud/ocis/v2/ocis-pkg/tracing"
|
||||
"github.com/owncloud/ocis/v2/services/graph/pkg/config"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
// TraceProvider is the global trace provider for the graph service.
|
||||
TraceProvider = trace.NewNoopTracerProvider()
|
||||
)
|
||||
|
||||
func Configure(cfg *config.Config) error {
|
||||
var err error
|
||||
if cfg.Tracing.Enabled {
|
||||
if TraceProvider, err = pkgtrace.GetTraceProvider(cfg.Tracing.Endpoint, cfg.Tracing.Collector, cfg.Service.Name, cfg.Tracing.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# backend
|
||||
-r '^(cmd|pkg)/.*\.go$' -R '^node_modules/' -s -- sh -c 'make bin/ocis-graph-debug && bin/ocis-graph-debug --log-level debug server --debug-pprof --debug-zpages --oidc-endpoint="https://deepdiver" --oidc-insecure=1'
|
||||
Reference in New Issue
Block a user