From 1912cd54cafb4ac2213f0c2fa8ef72644e526fd9 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Thu, 28 Mar 2024 13:23:55 +0100 Subject: [PATCH] Account: Add GET /api/v1/users/:uid/sessions endpoint #808 #4114 Signed-off-by: Michael Mayer --- internal/acl/acl.go | 2 +- internal/acl/acl_test.go | 88 +++++++-------- internal/acl/const.go | 84 ++++++++++++++ internal/acl/grant.go | 97 +++++++++++++--- internal/acl/grant_test.go | 42 +++++++ internal/acl/grants_test.go | 4 +- internal/acl/permission.go | 36 ------ internal/acl/permissions.go | 17 +++ internal/acl/resource_channels.go | 24 ---- internal/acl/resource_names.go | 28 ----- internal/acl/roles.go | 9 -- internal/acl/{resources.go => rules.go} | 14 +-- internal/acl/scopes.go | 37 +++++++ internal/acl/scopes_test.go | 37 +++++++ internal/api/api_auth.go | 38 +++---- internal/api/batch.go | 2 +- internal/api/config_settings.go | 4 +- internal/api/folders_search.go | 2 +- internal/api/import.go | 4 +- internal/api/photos_search.go | 2 +- internal/api/photos_search_geo.go | 2 +- internal/api/reactions.go | 8 +- internal/api/session_delete.go | 2 +- internal/api/users_avatar.go | 2 +- internal/api/users_password.go | 2 +- internal/api/users_sessions.go | 67 +++++++++++ internal/api/users_sessions_test.go | 48 ++++++++ internal/api/users_update.go | 2 +- internal/api/users_upload.go | 2 +- internal/config/client_config.go | 12 +- internal/config/settings.go | 6 +- internal/customize/acl_test.go | 4 +- internal/customize/scope_test.go | 6 +- internal/entity/auth_session.go | 44 +++++++- internal/entity/auth_session_login.go | 2 +- internal/entity/auth_session_login_test.go | 8 +- internal/entity/auth_session_test.go | 90 +++++++++++++++ internal/entity/auth_user.go | 10 +- internal/search/albums.go | 6 +- internal/search/photos.go | 10 +- internal/search/photos_geo.go | 8 +- internal/search/sessions.go | 17 +-- internal/server/routes.go | 1 + internal/server/webdav_auth.go | 2 +- internal/server/webdav_auth_test.go | 4 +- internal/server/wellknown/oauth.go | 2 +- internal/server/wellknown/openid.go | 2 +- pkg/list/attribute.go | 8 +- pkg/list/attributes.go | 49 +++++++-- pkg/list/attributes_test.go | 122 ++++++++++++++++++++- 50 files changed, 851 insertions(+), 268 deletions(-) create mode 100644 internal/acl/const.go create mode 100644 internal/acl/permissions.go delete mode 100644 internal/acl/resource_channels.go rename internal/acl/{resources.go => rules.go} (76%) create mode 100644 internal/acl/scopes.go create mode 100644 internal/acl/scopes_test.go create mode 100644 internal/api/users_sessions.go create mode 100644 internal/api/users_sessions_test.go diff --git a/internal/acl/acl.go b/internal/acl/acl.go index 606eb88e3..088dad9f7 100644 --- a/internal/acl/acl.go +++ b/internal/acl/acl.go @@ -27,7 +27,7 @@ package acl // ACL represents an access control list based on Resource, Roles, and Permissions. type ACL map[Resource]Roles -// Deny checks whether the role must be denied access to the specified resource. +// Deny checks whether the Role must be denied access to the specified Resource. func (acl ACL) Deny(resource Resource, role Role, perm Permission) bool { return !acl.Allow(resource, role, perm) } diff --git a/internal/acl/acl_test.go b/internal/acl/acl_test.go index 05a36119b..001c40336 100644 --- a/internal/acl/acl_test.go +++ b/internal/acl/acl_test.go @@ -8,130 +8,130 @@ import ( func TestACL_Allow(t *testing.T) { t.Run("ResourceSessions", func(t *testing.T) { - assert.True(t, Resources.Allow(ResourceSessions, RoleAdmin, AccessAll)) - assert.True(t, Resources.Allow(ResourceSessions, RoleAdmin, AccessOwn)) - assert.False(t, Resources.Allow(ResourceSessions, RoleVisitor, AccessAll)) - assert.True(t, Resources.Allow(ResourceSessions, RoleVisitor, AccessOwn)) - assert.False(t, Resources.Allow(ResourceSessions, RoleClient, AccessAll)) - assert.True(t, Resources.Allow(ResourceSessions, RoleClient, AccessOwn)) + assert.True(t, Rules.Allow(ResourceSessions, RoleAdmin, AccessAll)) + assert.True(t, Rules.Allow(ResourceSessions, RoleAdmin, AccessOwn)) + assert.False(t, Rules.Allow(ResourceSessions, RoleVisitor, AccessAll)) + assert.True(t, Rules.Allow(ResourceSessions, RoleVisitor, AccessOwn)) + assert.False(t, Rules.Allow(ResourceSessions, RoleClient, AccessAll)) + assert.True(t, Rules.Allow(ResourceSessions, RoleClient, AccessOwn)) }) t.Run("ResourcePhotosRoleAdminActionModify", func(t *testing.T) { - assert.True(t, Resources.Allow(ResourcePhotos, RoleAdmin, ActionUpdate)) + assert.True(t, Rules.Allow(ResourcePhotos, RoleAdmin, ActionUpdate)) }) t.Run("ResourceDefaultRoleAdminActionDefault", func(t *testing.T) { - assert.True(t, Resources.Allow(ResourceDefault, RoleAdmin, FullAccess)) + assert.True(t, Rules.Allow(ResourceDefault, RoleAdmin, FullAccess)) }) t.Run("ResourceDefaultRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.Allow(ResourceDefault, RoleVisitor, FullAccess)) + assert.False(t, Rules.Allow(ResourceDefault, RoleVisitor, FullAccess)) }) t.Run("ResourcePhotosRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.Allow(ResourcePhotos, RoleVisitor, FullAccess)) + assert.False(t, Rules.Allow(ResourcePhotos, RoleVisitor, FullAccess)) }) t.Run("ResourceAlbumsRoleVisitorAccessShared", func(t *testing.T) { - assert.True(t, Resources.Allow(ResourceAlbums, RoleVisitor, AccessShared)) + assert.True(t, Rules.Allow(ResourceAlbums, RoleVisitor, AccessShared)) }) t.Run("ResourceAlbumsRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.Allow(ResourceAlbums, RoleVisitor, FullAccess)) + assert.False(t, Rules.Allow(ResourceAlbums, RoleVisitor, FullAccess)) }) t.Run("WrongResourceRoleAdminActionDefault", func(t *testing.T) { - assert.True(t, Resources.Allow("wrong", RoleAdmin, FullAccess)) + assert.True(t, Rules.Allow("wrong", RoleAdmin, FullAccess)) }) t.Run("WrongResourceRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.Allow("wrong", RoleVisitor, FullAccess)) + assert.False(t, Rules.Allow("wrong", RoleVisitor, FullAccess)) }) } func TestACL_AllowAny(t *testing.T) { t.Run("Empty", func(t *testing.T) { - assert.False(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{})) + assert.False(t, Rules.AllowAny(ResourceAlbums, RoleVisitor, Permissions{})) }) t.Run("VisitorAccess", func(t *testing.T) { - assert.True(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessAll, AccessShared})) - assert.True(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessShared})) - assert.False(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessAll})) + assert.True(t, Rules.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessAll, AccessShared})) + assert.True(t, Rules.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessShared})) + assert.False(t, Rules.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessAll})) }) t.Run("ResourcePhotosRoleAdminActionModify", func(t *testing.T) { - assert.True(t, Resources.AllowAny(ResourcePhotos, RoleAdmin, Permissions{ActionUpdate})) + assert.True(t, Rules.AllowAny(ResourcePhotos, RoleAdmin, Permissions{ActionUpdate})) }) t.Run("ResourceDefaultRoleAdminActionDefault", func(t *testing.T) { - assert.True(t, Resources.AllowAny(ResourceDefault, RoleAdmin, Permissions{FullAccess})) + assert.True(t, Rules.AllowAny(ResourceDefault, RoleAdmin, Permissions{FullAccess})) }) t.Run("ResourceDefaultRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.AllowAny(ResourceDefault, RoleVisitor, Permissions{FullAccess})) + assert.False(t, Rules.AllowAny(ResourceDefault, RoleVisitor, Permissions{FullAccess})) }) t.Run("ResourcePhotosRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.AllowAny(ResourcePhotos, RoleVisitor, Permissions{FullAccess})) + assert.False(t, Rules.AllowAny(ResourcePhotos, RoleVisitor, Permissions{FullAccess})) }) t.Run("ResourceAlbumsRoleVisitorAccessShared", func(t *testing.T) { - assert.True(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessShared})) + assert.True(t, Rules.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessShared})) }) t.Run("ResourceAlbumsRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{FullAccess})) + assert.False(t, Rules.AllowAny(ResourceAlbums, RoleVisitor, Permissions{FullAccess})) }) } func TestACL_AllowAll(t *testing.T) { t.Run("Empty", func(t *testing.T) { - assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{})) + assert.False(t, Rules.AllowAll(ResourceAlbums, RoleVisitor, Permissions{})) }) t.Run("VisitorAccess", func(t *testing.T) { - assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessAll, AccessShared})) - assert.True(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessShared})) - assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessAll})) + assert.False(t, Rules.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessAll, AccessShared})) + assert.True(t, Rules.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessShared})) + assert.False(t, Rules.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessAll})) }) t.Run("ResourcePhotosRoleAdminActionModify", func(t *testing.T) { - assert.True(t, Resources.AllowAll(ResourcePhotos, RoleAdmin, Permissions{ActionUpdate})) + assert.True(t, Rules.AllowAll(ResourcePhotos, RoleAdmin, Permissions{ActionUpdate})) }) t.Run("ResourceDefaultRoleAdminActionDefault", func(t *testing.T) { - assert.True(t, Resources.AllowAll(ResourceDefault, RoleAdmin, Permissions{FullAccess})) + assert.True(t, Rules.AllowAll(ResourceDefault, RoleAdmin, Permissions{FullAccess})) }) t.Run("ResourceDefaultRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.AllowAll(ResourceDefault, RoleVisitor, Permissions{FullAccess})) + assert.False(t, Rules.AllowAll(ResourceDefault, RoleVisitor, Permissions{FullAccess})) }) t.Run("ResourcePhotosRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.AllowAll(ResourcePhotos, RoleVisitor, Permissions{FullAccess})) + assert.False(t, Rules.AllowAll(ResourcePhotos, RoleVisitor, Permissions{FullAccess})) }) t.Run("ResourceAlbumsRoleVisitorAccessShared", func(t *testing.T) { - assert.True(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessShared})) + assert.True(t, Rules.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessShared})) }) t.Run("ResourceAlbumsRoleVisitorActionDefault", func(t *testing.T) { - assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{FullAccess})) + assert.False(t, Rules.AllowAll(ResourceAlbums, RoleVisitor, Permissions{FullAccess})) }) t.Run("Empty", func(t *testing.T) { - assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{})) + assert.False(t, Rules.AllowAll(ResourceAlbums, RoleVisitor, Permissions{})) }) } func TestACL_Deny(t *testing.T) { t.Run("ResourceDefaultRoleAdminActionDefault", func(t *testing.T) { - assert.False(t, Resources.Deny(ResourceDefault, RoleAdmin, FullAccess)) + assert.False(t, Rules.Deny(ResourceDefault, RoleAdmin, FullAccess)) }) t.Run("ResourceDefaultRoleVisitorActionDefault", func(t *testing.T) { - assert.True(t, Resources.Deny(ResourceDefault, RoleVisitor, FullAccess)) + assert.True(t, Rules.Deny(ResourceDefault, RoleVisitor, FullAccess)) }) t.Run("ResourceAlbumsRoleVisitorActionAccessShared", func(t *testing.T) { - assert.False(t, Resources.Deny(ResourceAlbums, RoleVisitor, AccessShared)) + assert.False(t, Rules.Deny(ResourceAlbums, RoleVisitor, AccessShared)) }) t.Run("ResourcePhotosRoleVisitorActionDefault", func(t *testing.T) { - assert.True(t, Resources.Deny(ResourcePhotos, RoleVisitor, FullAccess)) + assert.True(t, Rules.Deny(ResourcePhotos, RoleVisitor, FullAccess)) }) t.Run("ResourceAlbumsRoleVisitorActionDefault", func(t *testing.T) { - assert.True(t, Resources.Deny(ResourceAlbums, RoleVisitor, FullAccess)) + assert.True(t, Rules.Deny(ResourceAlbums, RoleVisitor, FullAccess)) }) } func TestACL_DenyAll(t *testing.T) { t.Run("ResourceFilesRoleVisitorActionDefault", func(t *testing.T) { - assert.True(t, Resources.DenyAll(ResourceFiles, RoleVisitor, Permissions{FullAccess, AccessShared, ActionView})) + assert.True(t, Rules.DenyAll(ResourceFiles, RoleVisitor, Permissions{FullAccess, AccessShared, ActionView})) }) t.Run("ResourceFilesRoleAdminActionDefault", func(t *testing.T) { - assert.False(t, Resources.DenyAll(ResourceFiles, RoleAdmin, Permissions{FullAccess, AccessShared, ActionView})) + assert.False(t, Rules.DenyAll(ResourceFiles, RoleAdmin, Permissions{FullAccess, AccessShared, ActionView})) }) } func TestACL_Resources(t *testing.T) { - t.Run("Resources", func(t *testing.T) { - result := Resources.Resources() + t.Run("Rules", func(t *testing.T) { + result := Rules.Resources() assert.Len(t, result, len(ResourceNames)-1) }) } diff --git a/internal/acl/const.go b/internal/acl/const.go new file mode 100644 index 000000000..21e10262e --- /dev/null +++ b/internal/acl/const.go @@ -0,0 +1,84 @@ +package acl + +// Roles that can be granted Permissions to use a Resource. +const ( + RoleDefault Role = "default" + RoleAdmin Role = "admin" + RoleVisitor Role = "visitor" + RoleClient Role = "client" + RoleNone Role = "" +) + +// Permissions to use a Resource that can be granted to a Role. +const ( + FullAccess Permission = "full_access" + AccessShared Permission = "access_shared" + AccessLibrary Permission = "access_library" + AccessPrivate Permission = "access_private" + AccessOwn Permission = "access_own" + AccessAll Permission = "access_all" + ActionSearch Permission = "search" + ActionView Permission = "view" + ActionUpload Permission = "upload" + ActionCreate Permission = "create" + ActionUpdate Permission = "update" + ActionDownload Permission = "download" + ActionShare Permission = "share" + ActionDelete Permission = "delete" + ActionRate Permission = "rate" + ActionReact Permission = "react" + ActionSubscribe Permission = "subscribe" + ActionManage Permission = "manage" + ActionManageOwn Permission = "manage_own" +) + +// A Role can be given Permission to use a Resource. +const ( + ResourceFiles Resource = "files" + ResourceFolders Resource = "folders" + ResourceShares Resource = "shares" + ResourcePhotos Resource = "photos" + ResourceVideos Resource = "videos" + ResourceFavorites Resource = "favorites" + ResourceAlbums Resource = "albums" + ResourceMoments Resource = "moments" + ResourceCalendar Resource = "calendar" + ResourcePeople Resource = "people" + ResourcePlaces Resource = "places" + ResourceLabels Resource = "labels" + ResourceConfig Resource = "config" + ResourceSettings Resource = "settings" + ResourcePasscode Resource = "passcode" + ResourcePassword Resource = "password" + ResourceServices Resource = "services" + ResourceUsers Resource = "users" + ResourceSessions Resource = "sessions" + ResourceLogs Resource = "logs" + ResourceWebDAV Resource = "webdav" + ResourceMetrics Resource = "metrics" + ResourceFeedback Resource = "feedback" + ResourceDefault Resource = "default" +) + +// Events for which a Role can be granted the ActionSubscribe Permission. +const ( + ChannelUser Resource = "user" + ChannelSession Resource = "session" + ChannelAudit Resource = "audit" + ChannelLog Resource = "log" + ChannelNotify Resource = "notify" + ChannelIndex Resource = "index" + ChannelUpload Resource = "upload" + ChannelImport Resource = "import" + ChannelConfig Resource = "config" + ChannelCount Resource = "count" + ChannelPhotos Resource = "photos" + ChannelCameras Resource = "cameras" + ChannelLenses Resource = "lenses" + ChannelCountries Resource = "countries" + ChannelAlbums Resource = "albums" + ChannelLabels Resource = "labels" + ChannelSubjects Resource = "subjects" + ChannelPeople Resource = "people" + ChannelSync Resource = "sync" +) diff --git a/internal/acl/grant.go b/internal/acl/grant.go index 52bb86ceb..39d06604e 100644 --- a/internal/acl/grant.go +++ b/internal/acl/grant.go @@ -11,6 +11,7 @@ var ( AccessOwn: true, AccessShared: true, AccessLibrary: true, + ActionView: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, @@ -21,22 +22,66 @@ var ( ActionManage: true, ActionSubscribe: true, } - GrantSubscribeAll = Grant{ - AccessAll: true, - ActionSubscribe: true, - } - GrantSubscribeOwn = Grant{ + GrantOwn = Grant{ AccessOwn: true, + ActionView: true, + ActionCreate: true, + ActionUpdate: true, + ActionDelete: true, ActionSubscribe: true, } - GrantViewAll = Grant{ - AccessAll: true, - ActionView: true, + GrantAll = Grant{ + AccessAll: true, + AccessOwn: true, + ActionView: true, + ActionCreate: true, + ActionUpdate: true, + ActionDelete: true, + ActionSubscribe: true, + } + GrantManageOwn = Grant{ + AccessOwn: true, + ActionView: true, + ActionCreate: true, + ActionUpdate: true, + ActionDelete: true, + ActionSubscribe: true, + ActionManageOwn: true, + } + GrantConfigureOwn = Grant{ + AccessOwn: true, + ActionCreate: true, + ActionUpdate: true, + ActionDelete: true, + } + GrantUpdateOwn = Grant{ + AccessOwn: true, + ActionUpdate: true, } GrantViewOwn = Grant{ AccessOwn: true, ActionView: true, } + GrantViewUpdateOwn = Grant{ + AccessOwn: true, + ActionView: true, + ActionUpdate: true, + } + GrantViewLibrary = Grant{ + AccessLibrary: true, + ActionView: true, + } + GrantViewAll = Grant{ + AccessAll: true, + AccessOwn: true, + ActionView: true, + } + GrantViewUpdateAll = Grant{ + AccessAll: true, + AccessOwn: true, + ActionView: true, + ActionUpdate: true, + } GrantViewShared = Grant{ AccessShared: true, ActionView: true, @@ -48,10 +93,30 @@ var ( ActionView: true, ActionDownload: true, } + GrantSearchAll = Grant{ + AccessAll: true, + ActionView: true, + ActionSearch: true, + } + GrantSubscribeOwn = Grant{ + AccessOwn: true, + ActionSubscribe: true, + } + GrantSubscribeAll = Grant{ + AccessAll: true, + ActionSubscribe: true, + } GrantNone = Grant{} ) -// Allow checks whether the permission is granted. +// GrantDefaults defines default grants for all supported roles. +var GrantDefaults = Roles{ + RoleAdmin: GrantFullAccess, + RoleVisitor: GrantViewShared, + RoleClient: GrantFullAccess, +} + +// Allow checks if this Grant includes the specified Permission. func (grant Grant) Allow(perm Permission) bool { if result, ok := grant[perm]; ok { return result @@ -62,9 +127,13 @@ func (grant Grant) Allow(perm Permission) bool { return false } -// GrantDefaults defines default grants for all supported roles. -var GrantDefaults = Roles{ - RoleAdmin: GrantFullAccess, - RoleVisitor: GrantViewShared, - RoleClient: GrantFullAccess, +// DenyAny checks if any of the Permissions are not covered by this Grant. +func (grant Grant) DenyAny(perms Permissions) bool { + for i := range perms { + if !grant.Allow(perms[i]) { + return true + } + } + + return false } diff --git a/internal/acl/grant_test.go b/internal/acl/grant_test.go index d18ce395e..a4e68cc18 100644 --- a/internal/acl/grant_test.go +++ b/internal/acl/grant_test.go @@ -47,3 +47,45 @@ func TestGrant_Allow(t *testing.T) { assert.True(t, GrantViewOwn.Allow(AccessOwn)) }) } + +func TestGrant_DenyAny(t *testing.T) { + t.Run("GrantFullAccessAll", func(t *testing.T) { + assert.False(t, GrantFullAccess.DenyAny(Permissions{AccessAll})) + }) + t.Run("GrantFullAccessDownload", func(t *testing.T) { + assert.False(t, GrantFullAccess.DenyAny(Permissions{ActionDownload})) + }) + t.Run("GrantFullAccessDelete", func(t *testing.T) { + assert.False(t, GrantFullAccess.DenyAny(Permissions{ActionDelete})) + }) + t.Run("GrantFullAccessLibrary", func(t *testing.T) { + assert.False(t, GrantFullAccess.DenyAny(Permissions{AccessLibrary})) + }) + t.Run("UnknownAction", func(t *testing.T) { + assert.True(t, GrantViewAll.DenyAny(Permissions{"lovecats"})) + }) + t.Run("ViewAllView", func(t *testing.T) { + assert.False(t, GrantViewAll.DenyAny(Permissions{ActionView})) + }) + t.Run("ViewAllAccessAll", func(t *testing.T) { + assert.False(t, GrantViewAll.DenyAny(Permissions{AccessAll})) + }) + t.Run("ViewAllDownload", func(t *testing.T) { + assert.True(t, GrantViewAll.DenyAny(Permissions{ActionDownload})) + }) + t.Run("ViewAllShare", func(t *testing.T) { + assert.True(t, GrantViewAll.DenyAny(Permissions{ActionShare})) + }) + t.Run("ViewOwnShare", func(t *testing.T) { + assert.True(t, GrantViewOwn.DenyAny(Permissions{ActionShare})) + }) + t.Run("ViewOwnView", func(t *testing.T) { + assert.False(t, GrantViewOwn.DenyAny(Permissions{ActionView})) + }) + t.Run("ViewOwnAccessAll", func(t *testing.T) { + assert.True(t, GrantViewOwn.DenyAny(Permissions{AccessAll})) + }) + t.Run("ViewOwnAccessOwn", func(t *testing.T) { + assert.False(t, GrantViewOwn.DenyAny(Permissions{AccessOwn})) + }) +} diff --git a/internal/acl/grants_test.go b/internal/acl/grants_test.go index 8340fe05f..c17d91141 100644 --- a/internal/acl/grants_test.go +++ b/internal/acl/grants_test.go @@ -8,12 +8,12 @@ import ( func TestACL_Grants(t *testing.T) { t.Run("RoleAdmin", func(t *testing.T) { - result := Resources.Grants(RoleAdmin) + result := Rules.Grants(RoleAdmin) assert.True(t, result[ResourcePhotos][ActionManage]) assert.True(t, result[ResourceConfig][ActionManage]) }) t.Run("RoleVisitor", func(t *testing.T) { - result := Resources.Grants(RoleVisitor) + result := Rules.Grants(RoleVisitor) assert.False(t, result[ResourcePhotos][ActionUpdate]) assert.False(t, result[ResourceConfig][ActionManage]) }) diff --git a/internal/acl/permission.go b/internal/acl/permission.go index 09027248c..e3c43e2f6 100644 --- a/internal/acl/permission.go +++ b/internal/acl/permission.go @@ -5,28 +5,6 @@ import "strings" // Permission represents a single ability. type Permission string -// Permissions that can be granted to roles. -const ( - FullAccess Permission = "full_access" - AccessShared Permission = "access_shared" - AccessLibrary Permission = "access_library" - AccessPrivate Permission = "access_private" - AccessOwn Permission = "access_own" - AccessAll Permission = "access_all" - ActionSearch Permission = "search" - ActionView Permission = "view" - ActionUpload Permission = "upload" - ActionCreate Permission = "create" - ActionUpdate Permission = "update" - ActionDownload Permission = "download" - ActionShare Permission = "share" - ActionDelete Permission = "delete" - ActionRate Permission = "rate" - ActionReact Permission = "react" - ActionManage Permission = "manage" - ActionSubscribe Permission = "subscribe" -) - // String returns the type as string. func (p Permission) String() string { return strings.ReplaceAll(string(p), "_", " ") @@ -46,17 +24,3 @@ func (p Permission) Equal(s string) bool { func (p Permission) NotEqual(s string) bool { return !p.Equal(s) } - -// Permissions is a list of permissions. -type Permissions []Permission - -// String returns the permissions as a comma-separated string. -func (perm Permissions) String() string { - s := make([]string, len(perm)) - - for i := range perm { - s[i] = perm[i].String() - } - - return strings.Join(s, ", ") -} diff --git a/internal/acl/permissions.go b/internal/acl/permissions.go new file mode 100644 index 000000000..803882555 --- /dev/null +++ b/internal/acl/permissions.go @@ -0,0 +1,17 @@ +package acl + +import "strings" + +// Permissions represents a list of permissions. +type Permissions []Permission + +// String returns the permissions as a comma-separated string. +func (perm Permissions) String() string { + s := make([]string, len(perm)) + + for i := range perm { + s[i] = perm[i].String() + } + + return strings.Join(s, ", ") +} diff --git a/internal/acl/resource_channels.go b/internal/acl/resource_channels.go deleted file mode 100644 index 0935fb576..000000000 --- a/internal/acl/resource_channels.go +++ /dev/null @@ -1,24 +0,0 @@ -package acl - -// Events that Roles can be granted Permission to listen to. -const ( - ChannelUser Resource = "user" - ChannelSession Resource = "session" - ChannelAudit Resource = "audit" - ChannelLog Resource = "log" - ChannelNotify Resource = "notify" - ChannelIndex Resource = "index" - ChannelUpload Resource = "upload" - ChannelImport Resource = "import" - ChannelConfig Resource = "config" - ChannelCount Resource = "count" - ChannelPhotos Resource = "photos" - ChannelCameras Resource = "cameras" - ChannelLenses Resource = "lenses" - ChannelCountries Resource = "countries" - ChannelAlbums Resource = "albums" - ChannelLabels Resource = "labels" - ChannelSubjects Resource = "subjects" - ChannelPeople Resource = "people" - ChannelSync Resource = "sync" -) diff --git a/internal/acl/resource_names.go b/internal/acl/resource_names.go index 980ad51d7..62abf0475 100644 --- a/internal/acl/resource_names.go +++ b/internal/acl/resource_names.go @@ -1,33 +1,5 @@ package acl -// Resources that Roles can be granted Permission. -const ( - ResourceFiles Resource = "files" - ResourceFolders Resource = "folders" - ResourceShares Resource = "shares" - ResourcePhotos Resource = "photos" - ResourceVideos Resource = "videos" - ResourceFavorites Resource = "favorites" - ResourceAlbums Resource = "albums" - ResourceMoments Resource = "moments" - ResourceCalendar Resource = "calendar" - ResourcePeople Resource = "people" - ResourcePlaces Resource = "places" - ResourceLabels Resource = "labels" - ResourceConfig Resource = "config" - ResourceSettings Resource = "settings" - ResourcePasscode Resource = "passcode" - ResourcePassword Resource = "password" - ResourceServices Resource = "services" - ResourceUsers Resource = "users" - ResourceSessions Resource = "sessions" - ResourceLogs Resource = "logs" - ResourceWebDAV Resource = "webdav" - ResourceMetrics Resource = "metrics" - ResourceFeedback Resource = "feedback" - ResourceDefault Resource = "default" -) - // ResourceNames contains a list of all specified resources. var ResourceNames = []Resource{ ResourceFiles, diff --git a/internal/acl/roles.go b/internal/acl/roles.go index 53bde0a3f..1ed355f52 100644 --- a/internal/acl/roles.go +++ b/internal/acl/roles.go @@ -1,14 +1,5 @@ package acl -// Roles that can be assigned to users. -const ( - RoleDefault Role = "default" - RoleAdmin Role = "admin" - RoleVisitor Role = "visitor" - RoleClient Role = "client" - RoleNone Role = "" -) - // RoleStrings represents user role names mapped to roles. type RoleStrings = map[string]Role diff --git a/internal/acl/resources.go b/internal/acl/rules.go similarity index 76% rename from internal/acl/resources.go rename to internal/acl/rules.go index 5c61390ae..57890f50d 100644 --- a/internal/acl/resources.go +++ b/internal/acl/rules.go @@ -1,7 +1,7 @@ package acl -// Resources specifies granted permissions by Resource and Role. -var Resources = ACL{ +// Rules specifies granted permissions by Resource and Role. +var Rules = ACL{ ResourceFiles: Roles{ RoleAdmin: GrantFullAccess, RoleClient: GrantFullAccess, @@ -51,8 +51,8 @@ var Resources = ACL{ }, ResourceSettings: Roles{ RoleAdmin: GrantFullAccess, - RoleVisitor: Grant{AccessOwn: true, ActionView: true}, - RoleClient: Grant{AccessOwn: true, ActionView: true, ActionUpdate: true}, + RoleVisitor: GrantViewOwn, + RoleClient: GrantViewUpdateOwn, }, ResourceServices: Roles{ RoleAdmin: GrantFullAccess, @@ -64,12 +64,12 @@ var Resources = ACL{ RoleAdmin: GrantFullAccess, }, ResourceUsers: Roles{ - RoleAdmin: Grant{AccessAll: true, AccessOwn: true, ActionView: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionSubscribe: true}, - RoleClient: Grant{AccessOwn: true, ActionView: true}, + RoleAdmin: GrantAll, + RoleClient: GrantViewOwn, }, ResourceSessions: Roles{ RoleAdmin: GrantFullAccess, - RoleDefault: Grant{AccessOwn: true, ActionView: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionSubscribe: true}, + RoleDefault: GrantOwn, }, ResourceLogs: Roles{ RoleAdmin: GrantFullAccess, diff --git a/internal/acl/scopes.go b/internal/acl/scopes.go new file mode 100644 index 000000000..18d8bfdbc --- /dev/null +++ b/internal/acl/scopes.go @@ -0,0 +1,37 @@ +package acl + +// Permission scopes to Grant multiple Permissions for a Resource. +const ( + ScopeRead Permission = "read" + ScopeWrite Permission = "write" +) + +var ( + GrantScopeRead = Grant{ + AccessShared: true, + AccessLibrary: true, + AccessPrivate: true, + AccessOwn: true, + AccessAll: true, + ActionSearch: true, + ActionView: true, + ActionDownload: true, + ActionSubscribe: true, + } + GrantScopeWrite = Grant{ + AccessShared: true, + AccessLibrary: true, + AccessPrivate: true, + AccessOwn: true, + AccessAll: true, + ActionUpload: true, + ActionCreate: true, + ActionUpdate: true, + ActionShare: true, + ActionDelete: true, + ActionRate: true, + ActionReact: true, + ActionManage: true, + ActionManageOwn: true, + } +) diff --git a/internal/acl/scopes_test.go b/internal/acl/scopes_test.go new file mode 100644 index 000000000..8b06d1015 --- /dev/null +++ b/internal/acl/scopes_test.go @@ -0,0 +1,37 @@ +package acl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGrantScopeRead(t *testing.T) { + t.Run("ActionView", func(t *testing.T) { + assert.True(t, GrantScopeRead.Allow(ActionView)) + assert.False(t, GrantScopeRead.DenyAny(Permissions{ActionView})) + }) + t.Run("ActionUpdate", func(t *testing.T) { + assert.False(t, GrantScopeRead.Allow(ActionUpdate)) + assert.True(t, GrantScopeRead.DenyAny(Permissions{ActionUpdate})) + }) + t.Run("AccessAll", func(t *testing.T) { + assert.True(t, GrantScopeRead.Allow(AccessAll)) + assert.False(t, GrantScopeRead.DenyAny(Permissions{AccessAll})) + }) +} + +func TestGrantScopeWrite(t *testing.T) { + t.Run("ActionView", func(t *testing.T) { + assert.False(t, GrantScopeWrite.Allow(ActionView)) + assert.True(t, GrantScopeWrite.DenyAny(Permissions{ActionView})) + }) + t.Run("ActionUpdate", func(t *testing.T) { + assert.True(t, GrantScopeWrite.Allow(ActionUpdate)) + assert.False(t, GrantScopeWrite.DenyAny(Permissions{ActionUpdate})) + }) + t.Run("AccessAll", func(t *testing.T) { + assert.True(t, GrantScopeWrite.Allow(AccessAll)) + assert.False(t, GrantScopeWrite.DenyAny(Permissions{AccessAll})) + }) +} diff --git a/internal/api/api_auth.go b/internal/api/api_auth.go index b8f0720fe..03bec0de3 100644 --- a/internal/api/api_auth.go +++ b/internal/api/api_auth.go @@ -12,13 +12,13 @@ import ( // Auth checks if the user is authorized to access a resource with the given permission // and returns the session or nil otherwise. -func Auth(c *gin.Context, resource acl.Resource, grant acl.Permission) *entity.Session { - return AuthAny(c, resource, acl.Permissions{grant}) +func Auth(c *gin.Context, resource acl.Resource, perm acl.Permission) *entity.Session { + return AuthAny(c, resource, acl.Permissions{perm}) } // AuthAny checks if the user is authorized to access a resource with any of the specified permissions // and returns the session or nil otherwise. -func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s *entity.Session) { +func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *entity.Session) { // Prevent CDNs from caching responses that require authentication. if header.IsCdn(c.Request) { return entity.SessionStatusForbidden() @@ -30,7 +30,7 @@ func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s * // Find active session to perform authorization check or deny if no session was found. if s = Session(clientIp, authToken); s == nil { - event.AuditWarn([]string{clientIp, "%s %s without authentication", "denied"}, grants.String(), string(resource)) + event.AuditWarn([]string{clientIp, "%s %s without authentication", "denied"}, perms.String(), string(resource)) return entity.SessionStatusUnauthorized() } @@ -41,33 +41,33 @@ func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s * // If the request is from a client application, check its authorization based // on the allowed scope, the ACL, and the user account it belongs to (if any). if s.IsClient() { - // Check ACL resource name against the permitted scope. - if !s.HasScope(resource.String()) { + // Check the resource and required permissions against the session scope. + if s.ScopeExcludes(resource, perms) { event.AuditErr([]string{clientIp, "client %s", "session %s", "access %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, string(resource)) return entity.SessionStatusForbidden() } - // Perform an authorization check based on the ACL defaults for client applications. - if acl.Resources.DenyAll(resource, s.ClientRole(), grants) { - event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource)) + // Check request authorization against client application ACL rules. + if acl.Rules.DenyAll(resource, s.ClientRole(), perms) { + event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource)) return entity.SessionStatusForbidden() } - // Additionally check the user authorization if the client belongs to a user account. + // Also check the request authorization against the user's ACL rules? if s.NoUser() { // Allow access based on the ACL defaults for client applications. - event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s", "granted"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource)) + event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s", "granted"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource)) } else if u := s.User(); !u.IsDisabled() && !u.IsUnknown() && u.IsRegistered() { - if acl.Resources.DenyAll(resource, u.AclRole(), grants) { - event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource), u.String()) + if acl.Rules.DenyAll(resource, u.AclRole(), perms) { + event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as %s", "denied"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource), u.String()) return entity.SessionStatusForbidden() } // Allow access based on the user role. - event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s as %s", "granted"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource), u.String()) + event.AuditInfo([]string{clientIp, "client %s", "session %s", "%s %s as %s", "granted"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource), u.String()) } else { // Deny access if it is not a regular user account or the account has been disabled. - event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as unauthorized user", "denied"}, clean.Log(s.ClientInfo()), s.RefID, grants.String(), string(resource)) + event.AuditErr([]string{clientIp, "client %s", "session %s", "%s %s as unauthorized user", "denied"}, clean.Log(s.ClientInfo()), s.RefID, perms.String(), string(resource)) return entity.SessionStatusForbidden() } @@ -76,13 +76,13 @@ func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) (s * // Otherwise, perform a regular ACL authorization check based on the user role. if u := s.User(); u.IsUnknown() || u.IsDisabled() { - event.AuditWarn([]string{clientIp, "session %s", "%s %s as unauthorized user", "denied"}, s.RefID, grants.String(), string(resource)) + event.AuditWarn([]string{clientIp, "session %s", "%s %s as unauthorized user", "denied"}, s.RefID, perms.String(), string(resource)) return entity.SessionStatusUnauthorized() - } else if acl.Resources.DenyAll(resource, u.AclRole(), grants) { - event.AuditErr([]string{clientIp, "session %s", "%s %s as %s", "denied"}, s.RefID, grants.String(), string(resource), u.AclRole().String()) + } else if acl.Rules.DenyAll(resource, u.AclRole(), perms) { + event.AuditErr([]string{clientIp, "session %s", "%s %s as %s", "denied"}, s.RefID, perms.String(), string(resource), u.AclRole().String()) return entity.SessionStatusForbidden() } else { - event.AuditInfo([]string{clientIp, "session %s", "%s %s as %s", "granted"}, s.RefID, grants.String(), string(resource), u.AclRole().String()) + event.AuditInfo([]string{clientIp, "session %s", "%s %s as %s", "granted"}, s.RefID, perms.String(), string(resource), u.AclRole().String()) return s } } diff --git a/internal/api/batch.go b/internal/api/batch.go index d6e8b511a..44caebf4a 100644 --- a/internal/api/batch.go +++ b/internal/api/batch.go @@ -367,7 +367,7 @@ func BatchPhotosDelete(router *gin.RouterGroup) { var err error // Abort if user wants to delete all but does not have sufficient privileges. - if f.All && !acl.Resources.AllowAll(acl.ResourcePhotos, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { + if f.All && !acl.Rules.AllowAll(acl.ResourcePhotos, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { AbortForbidden(c) return } diff --git a/internal/api/config_settings.go b/internal/api/config_settings.go index 2fe6e9c23..6d3a5bc55 100644 --- a/internal/api/config_settings.go +++ b/internal/api/config_settings.go @@ -91,8 +91,8 @@ func SaveSettings(router *gin.RouterGroup) { return } - if acl.Resources.DenyAll(acl.ResourceSettings, s.UserRole(), acl.Permissions{acl.ActionUpdate, acl.ActionManage}) { - c.JSON(http.StatusOK, user.Settings().Apply(settings).ApplyTo(conf.Settings().ApplyACL(acl.Resources, user.AclRole()))) + if acl.Rules.DenyAll(acl.ResourceSettings, s.UserRole(), acl.Permissions{acl.ActionUpdate, acl.ActionManage}) { + c.JSON(http.StatusOK, user.Settings().Apply(settings).ApplyTo(conf.Settings().ApplyACL(acl.Rules, user.AclRole()))) return } else if err := user.Settings().Apply(settings).Save(); err != nil { log.Debugf("config: %s (save user settings)", err) diff --git a/internal/api/folders_search.go b/internal/api/folders_search.go index 2eb213962..baa9fc7a6 100644 --- a/internal/api/folders_search.go +++ b/internal/api/folders_search.go @@ -68,7 +68,7 @@ func SearchFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string) // Exclude private content? if !get.Config().Settings().Features.Private { f.Public = false - } else if acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.AccessPrivate) { + } else if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.AccessPrivate) { f.Public = true } diff --git a/internal/api/import.go b/internal/api/import.go index 3e844fa8a..8feddb437 100644 --- a/internal/api/import.go +++ b/internal/api/import.go @@ -68,7 +68,7 @@ func StartImport(router *gin.RouterGroup) { if token := path.Base(srcFolder); token != "" && path.Dir(srcFolder) == UploadPath { srcFolder = path.Join(UploadPath, s.RefID+token) event.AuditInfo([]string{ClientIP(c), "session %s", "import uploads from %s as %s", "granted"}, s.RefID, clean.Log(srcFolder), s.UserRole().String()) - } else if acl.Resources.Deny(acl.ResourceFiles, s.UserRole(), acl.ActionManage) { + } else if acl.Rules.Deny(acl.ResourceFiles, s.UserRole(), acl.ActionManage) { event.AuditErr([]string{ClientIP(c), "session %s", "import files from %s as %s", "denied"}, s.RefID, clean.Log(srcFolder), s.UserRole().String()) AbortForbidden(c) return @@ -99,7 +99,7 @@ func StartImport(router *gin.RouterGroup) { // Add imported files to albums if allowed. if len(f.Albums) > 0 && - acl.Resources.AllowAny(acl.ResourceAlbums, s.UserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) { + acl.Rules.AllowAny(acl.ResourceAlbums, s.UserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) { log.Debugf("import: adding files to album %s", clean.Log(strings.Join(f.Albums, " and "))) opt.Albums = f.Albums } diff --git a/internal/api/photos_search.go b/internal/api/photos_search.go index 2b9e93c42..db6db7c74 100644 --- a/internal/api/photos_search.go +++ b/internal/api/photos_search.go @@ -46,7 +46,7 @@ func SearchPhotos(router *gin.RouterGroup) { // Ignore private flag if feature is disabled. if f.Scope == "" && settings.Features.Review && - acl.Resources.Deny(acl.ResourcePhotos, s.UserRole(), acl.ActionManage) { + acl.Rules.Deny(acl.ResourcePhotos, s.UserRole(), acl.ActionManage) { f.Quality = 3 } diff --git a/internal/api/photos_search_geo.go b/internal/api/photos_search_geo.go index 77584b6ad..28824e671 100644 --- a/internal/api/photos_search_geo.go +++ b/internal/api/photos_search_geo.go @@ -50,7 +50,7 @@ func SearchGeo(router *gin.RouterGroup) { // Ignore private flag if feature is disabled. if f.Scope == "" && settings.Features.Review && - acl.Resources.Deny(acl.ResourcePhotos, s.UserRole(), acl.ActionManage) { + acl.Rules.Deny(acl.ResourcePhotos, s.UserRole(), acl.ActionManage) { f.Quality = 3 } diff --git a/internal/api/reactions.go b/internal/api/reactions.go index 5253a9c9d..1f02cf717 100644 --- a/internal/api/reactions.go +++ b/internal/api/reactions.go @@ -31,11 +31,11 @@ func LikePhoto(router *gin.RouterGroup) { return } - if get.Config().Experimental() && acl.Resources.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionReact) { + if get.Config().Experimental() && acl.Rules.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionReact) { logWarn("react", m.React(s.User(), react.Find("love"))) } - if acl.Resources.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionUpdate) { + if acl.Rules.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionUpdate) { err = m.SetFavorite(true) if err != nil { @@ -71,11 +71,11 @@ func DislikePhoto(router *gin.RouterGroup) { return } - if get.Config().Experimental() && acl.Resources.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionReact) { + if get.Config().Experimental() && acl.Rules.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionReact) { logWarn("react", m.UnReact(s.User())) } - if acl.Resources.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionUpdate) { + if acl.Rules.Allow(acl.ResourcePhotos, s.UserRole(), acl.ActionUpdate) { err = m.SetFavorite(false) if err != nil { diff --git a/internal/api/session_delete.go b/internal/api/session_delete.go index 10ca5066b..037291510 100644 --- a/internal/api/session_delete.go +++ b/internal/api/session_delete.go @@ -49,7 +49,7 @@ func DeleteSession(router *gin.RouterGroup) { // Only admins may delete other sessions by ref id. if rnd.IsRefID(id) { - if !acl.Resources.AllowAll(acl.ResourceSessions, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { + if !acl.Rules.AllowAll(acl.ResourceSessions, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) { event.AuditErr([]string{clientIp, "session %s", "delete %s as %s", "denied"}, s.RefID, acl.ResourceSessions.String(), s.UserRole()) Abort(c, http.StatusForbidden, i18n.ErrForbidden) return diff --git a/internal/api/users_avatar.go b/internal/api/users_avatar.go index a01ffd3d3..62844318b 100644 --- a/internal/api/users_avatar.go +++ b/internal/api/users_avatar.go @@ -36,7 +36,7 @@ func UploadUserAvatar(router *gin.RouterGroup) { } // Check if the session user is has user management privileges. - isAdmin := acl.Resources.AllowAll(acl.ResourceUsers, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) + isAdmin := acl.Rules.AllowAll(acl.ResourceUsers, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) uid := clean.UID(c.Param("uid")) // Users may only change their own avatar. diff --git a/internal/api/users_password.go b/internal/api/users_password.go index 5ba96cb82..f25907122 100644 --- a/internal/api/users_password.go +++ b/internal/api/users_password.go @@ -43,7 +43,7 @@ func UpdateUserPassword(router *gin.RouterGroup) { } // Check if the current user has management privileges. - isAdmin := acl.Resources.AllowAll(acl.ResourceUsers, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) + isAdmin := acl.Rules.AllowAll(acl.ResourceUsers, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) isSuperAdmin := isAdmin && s.User().IsSuperAdmin() uid := clean.UID(c.Param("uid")) diff --git a/internal/api/users_sessions.go b/internal/api/users_sessions.go new file mode 100644 index 000000000..62b0452d7 --- /dev/null +++ b/internal/api/users_sessions.go @@ -0,0 +1,67 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + + "github.com/photoprism/photoprism/internal/acl" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/internal/get" + "github.com/photoprism/photoprism/internal/search" + "github.com/photoprism/photoprism/pkg/rnd" +) + +// FindUserSessions finds user sessions and returns them as JSON. +// +// GET /api/v1/users/:uid/sessions +func FindUserSessions(router *gin.RouterGroup) { + router.GET("/users/:uid/sessions", func(c *gin.Context) { + // Check if the session user is has user management privileges. + s := Auth(c, acl.ResourceSessions, acl.ActionManageOwn) + + if s.Abort(c) { + return + } + + // Get global config. + conf := get.Config() + + // Check feature flags and authorization. + if conf.Public() || conf.Demo() || !s.HasRegisteredUser() || conf.DisableSettings() { + c.JSON(http.StatusNotFound, entity.Users{}) + return + } else if !rnd.IsUID(s.UserUID, entity.UserUID) || s.UserUID != c.Param("uid") { + c.JSON(http.StatusForbidden, entity.Users{}) + return + } + + var f form.SearchSessions + + // Init search form. + err := c.MustBindWith(&f, binding.Form) + + if err != nil { + AbortBadRequest(c) + return + } + + // Filter by user. + f.UID = s.UserUID + + // Perform search. + result, err := search.Sessions(f) + + if err != nil { + AbortBadRequest(c) + return + } + + AddLimitHeader(c, f.Count) + AddOffsetHeader(c, f.Offset) + + c.JSON(http.StatusOK, result) + }) +} diff --git a/internal/api/users_sessions_test.go b/internal/api/users_sessions_test.go new file mode 100644 index 000000000..baf32d6af --- /dev/null +++ b/internal/api/users_sessions_test.go @@ -0,0 +1,48 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/photoprism/photoprism/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestFindUserSessions(t *testing.T) { + t.Run("Public", func(t *testing.T) { + app, router, _ := NewApiTest() + FindUserSessions(router) + r := PerformRequest(app, "GET", "/api/v1/users/uqxc08w3d0ej2283/sessions") + assert.Equal(t, http.StatusNotFound, r.Code) + }) + t.Run("Unauthorized", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + FindUserSessions(router) + + r := PerformRequest(app, "GET", "/api/v1/users/uqxc08w3d0ej2283/sessions") + assert.Equal(t, http.StatusUnauthorized, r.Code) + }) + t.Run("InvalidUID", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + FindUserSessions(router) + sessId := AuthenticateUser(app, router, "alice", "Alice123!") + + r := AuthenticatedRequest(app, "GET", "/api/v1/users/uqxetseacy5eo9z2/sessions?count=100", sessId) + assert.Equal(t, http.StatusForbidden, r.Code) + }) + t.Run("Success", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + FindUserSessions(router) + sessId := AuthenticateUser(app, router, "alice", "Alice123!") + + r := AuthenticatedRequest(app, "GET", "/api/v1/users/uqxetse3cy5eo9z2/sessions?count=100", sessId) + assert.Equal(t, http.StatusOK, r.Code) + // t.Logf("FindUserSessions/Success: %s", r.Body.String()) + }) +} diff --git a/internal/api/users_update.go b/internal/api/users_update.go index 15b3da742..7bc683d80 100644 --- a/internal/api/users_update.go +++ b/internal/api/users_update.go @@ -61,7 +61,7 @@ func UpdateUser(router *gin.RouterGroup) { } // Check if the session user is has user management privileges. - isAdmin := acl.Resources.AllowAll(acl.ResourceUsers, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) + isAdmin := acl.Rules.AllowAll(acl.ResourceUsers, s.UserRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) privilegeLevelChange := isAdmin && m.PrivilegeLevelChange(f) // Get user from session. diff --git a/internal/api/users_upload.go b/internal/api/users_upload.go index 9b17a3aa5..a7ca3d7a7 100644 --- a/internal/api/users_upload.go +++ b/internal/api/users_upload.go @@ -197,7 +197,7 @@ func ProcessUserUpload(router *gin.RouterGroup) { // Add imported files to albums if allowed. if len(f.Albums) > 0 && - acl.Resources.AllowAny(acl.ResourceAlbums, s.UserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) { + acl.Rules.AllowAny(acl.ResourceAlbums, s.UserRole(), acl.Permissions{acl.ActionCreate, acl.ActionUpload}) { log.Debugf("upload: adding files to album %s", clean.Log(strings.Join(f.Albums, " and "))) opt.Albums = f.Albums } diff --git a/internal/config/client_config.go b/internal/config/client_config.go index efa916d01..10ed1db32 100644 --- a/internal/config/client_config.go +++ b/internal/config/client_config.go @@ -221,14 +221,14 @@ func (c *Config) Flags() (flags []string) { // ClientPublic returns config values for use by the JavaScript UI and other clients. func (c *Config) ClientPublic() ClientConfig { if c.Public() { - return c.ClientUser(true).ApplyACL(acl.Resources, acl.RoleAdmin) + return c.ClientUser(true).ApplyACL(acl.Rules, acl.RoleAdmin) } a := c.ClientAssets() cfg := ClientConfig{ Settings: c.PublicSettings(), - ACL: acl.Resources.Grants(acl.RoleNone), + ACL: acl.Rules.Grants(acl.RoleNone), Disable: ClientDisable{ WebDAV: true, Settings: c.DisableSettings(), @@ -317,7 +317,7 @@ func (c *Config) ClientShare() ClientConfig { cfg := ClientConfig{ Settings: c.ShareSettings(), - ACL: acl.Resources.Grants(acl.RoleVisitor), + ACL: acl.Rules.Grants(acl.RoleVisitor), Disable: ClientDisable{ WebDAV: c.DisableWebDAV(), Settings: c.DisableSettings(), @@ -677,18 +677,18 @@ func (c *Config) ClientUser(withSettings bool) ClientConfig { // ClientRole provides the client config values for the specified user role. func (c *Config) ClientRole(role acl.Role) ClientConfig { - return c.ClientUser(true).ApplyACL(acl.Resources, role) + return c.ClientUser(true).ApplyACL(acl.Rules, role) } // ClientSession provides the client config values for the specified session. func (c *Config) ClientSession(sess *entity.Session) (cfg ClientConfig) { if sess.NoUser() && sess.IsClient() { - cfg = c.ClientUser(false).ApplyACL(acl.Resources, sess.ClientRole()) + cfg = c.ClientUser(false).ApplyACL(acl.Rules, sess.ClientRole()) cfg.Settings = c.SessionSettings(sess) } else if sess.User().IsVisitor() { cfg = c.ClientShare() } else if sess.User().IsRegistered() { - cfg = c.ClientUser(false).ApplyACL(acl.Resources, sess.UserRole()) + cfg = c.ClientUser(false).ApplyACL(acl.Rules, sess.UserRole()) cfg.Settings = c.SessionSettings(sess) } else { cfg = c.ClientPublic() diff --git a/internal/config/settings.go b/internal/config/settings.go index 99c34f33c..430d77423 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -73,7 +73,7 @@ func (c *Config) SessionSettings(sess *entity.Session) *customize.Settings { } if sess.NoUser() && sess.IsClient() { - return c.Settings().ApplyACL(acl.Resources, sess.ClientRole()).ApplyScope(sess.Scope()) + return c.Settings().ApplyACL(acl.Rules, sess.ClientRole()).ApplyScope(sess.Scope()) } user := sess.User() @@ -84,7 +84,7 @@ func (c *Config) SessionSettings(sess *entity.Session) *customize.Settings { } // Apply role-based permissions and user settings to a copy of the global app settings. - return user.Settings().ApplyTo(c.Settings().ApplyACL(acl.Resources, user.AclRole())).ApplyScope(sess.Scope()) + return user.Settings().ApplyTo(c.Settings().ApplyACL(acl.Rules, user.AclRole())).ApplyScope(sess.Scope()) } // PublicSettings returns the public app settings. @@ -102,7 +102,7 @@ func (c *Config) PublicSettings() *customize.Settings { // ShareSettings returns the app settings for share link visitors. func (c *Config) ShareSettings() *customize.Settings { - settings := c.Settings().ApplyACL(acl.Resources, acl.RoleVisitor) + settings := c.Settings().ApplyACL(acl.Rules, acl.RoleVisitor) return &customize.Settings{ UI: settings.UI, diff --git a/internal/customize/acl_test.go b/internal/customize/acl_test.go index 62cfc5644..918a80482 100644 --- a/internal/customize/acl_test.go +++ b/internal/customize/acl_test.go @@ -45,7 +45,7 @@ func TestSettings_ApplyACL(t *testing.T) { } assert.Equal(t, original, s.Features) - r := s.ApplyACL(acl.Resources, acl.RoleAdmin) + r := s.ApplyACL(acl.Rules, acl.RoleAdmin) t.Logf("RoleAdmin: %#v", r) assert.Equal(t, expected, r.Features) @@ -85,7 +85,7 @@ func TestSettings_ApplyACL(t *testing.T) { } assert.Equal(t, original, s.Features) - r := s.ApplyACL(acl.Resources, acl.RoleVisitor) + r := s.ApplyACL(acl.Rules, acl.RoleVisitor) t.Logf("RoleVisitor: %#v", r) assert.Equal(t, expected, r.Features) }) diff --git a/internal/customize/scope_test.go b/internal/customize/scope_test.go index 4f2d1ee3a..265543cae 100644 --- a/internal/customize/scope_test.go +++ b/internal/customize/scope_test.go @@ -10,9 +10,9 @@ import ( func TestSettings_ApplyScope(t *testing.T) { original := NewDefaultSettings().Features - admin := NewDefaultSettings().ApplyACL(acl.Resources, acl.RoleAdmin) - client := NewDefaultSettings().ApplyACL(acl.Resources, acl.RoleClient) - visitor := NewDefaultSettings().ApplyACL(acl.Resources, acl.RoleVisitor) + admin := NewDefaultSettings().ApplyACL(acl.Rules, acl.RoleAdmin) + client := NewDefaultSettings().ApplyACL(acl.Rules, acl.RoleClient) + visitor := NewDefaultSettings().ApplyACL(acl.Rules, acl.RoleVisitor) t.Run("AdminUnscoped", func(t *testing.T) { s := NewDefaultSettings() diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index 1550b0025..c272d39d1 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -518,9 +518,47 @@ func (m *Session) Scope() string { return clean.Scope(m.AuthScope) } -// HasScope checks if the session has the given authorization scope. -func (m *Session) HasScope(scope string) bool { - return list.ParseAttr(m.Scope()).Contains(scope) +// ScopeAllows checks if the scope does not exclude access to specified resource. +func (m *Session) ScopeAllows(resource acl.Resource, perms acl.Permissions) bool { + // Get scope string. + scope := m.Scope() + + // Skip detailed check and allow all if scope is "*". + if scope == list.All { + return true + } + + // Skip resource check if scope includes all read operations. + if scope == acl.ScopeRead.String() { + return !acl.GrantScopeRead.DenyAny(perms) + } + + // Parse scope to check for resources and permissions. + attr := list.ParseAttr(scope) + + // Check if resource is within scope. + if granted := attr.Contains(resource.String()); !granted { + return false + } + + // Check if permission is within scope. + if len(perms) == 0 { + return true + } + + // Check if scope is limited to read or write operations. + if a := attr.Find(acl.ScopeRead.String()); a.Value == list.True && acl.GrantScopeRead.DenyAny(perms) { + return false + } else if a = attr.Find(acl.ScopeWrite.String()); a.Value == list.True && acl.GrantScopeWrite.DenyAny(perms) { + return false + } + + return true +} + +// ScopeExcludes checks if the scope does not include access to specified resource. +func (m *Session) ScopeExcludes(resource acl.Resource, perms acl.Permissions) bool { + return !m.ScopeAllows(resource, perms) } // SetScope sets a custom authentication scope. diff --git a/internal/entity/auth_session_login.go b/internal/entity/auth_session_login.go index 68b6f99af..1d64ac0a2 100644 --- a/internal/entity/auth_session_login.go +++ b/internal/entity/auth_session_login.go @@ -127,7 +127,7 @@ func AuthLocal(user *User, f form.Login, m *Session, c *gin.Context) (provider a event.LoginError(clientIp, "api", userName, m.UserAgent, message) m.Status = http.StatusUnauthorized return provider, method, i18n.Error(i18n.ErrInvalidCredentials) - } else if !authSess.IsClient() || !authSess.HasScope(acl.ResourceSessions.String()) { + } else if !authSess.IsClient() || authSess.ScopeExcludes(acl.ResourceSessions, acl.Permissions{acl.ActionCreate}) { message := "unauthorized" limiter.Login.Reserve(clientIp) event.AuditErr([]string{clientIp, "session %s", "login as %s with app password", message}, m.RefID, clean.LogQuote(userName)) diff --git a/internal/entity/auth_session_login_test.go b/internal/entity/auth_session_login_test.go index cc44fa8b6..c6ab077fe 100644 --- a/internal/entity/auth_session_login_test.go +++ b/internal/entity/auth_session_login_test.go @@ -110,8 +110,8 @@ func TestAuthSession(t *testing.T) { assert.True(t, authSess.IsRegistered()) assert.True(t, authSess.HasUser()) - assert.True(t, authSess.HasScope(acl.ResourceWebDAV.String())) - assert.True(t, authSess.HasScope(acl.ResourceSessions.String())) + assert.True(t, authSess.ScopeAllows(acl.ResourceWebDAV, acl.Permissions{acl.ActionCreate})) + assert.True(t, authSess.ScopeAllows(acl.ResourceSessions, acl.Permissions{acl.ActionCreate})) }) t.Run("AliceTokenWebdav", func(t *testing.T) { s := SessionFixtures.Get("alice_token_webdav") @@ -148,8 +148,8 @@ func TestAuthSession(t *testing.T) { assert.True(t, authSess.IsRegistered()) assert.True(t, authSess.HasUser()) - assert.True(t, authSess.HasScope(acl.ResourceWebDAV.String())) - assert.False(t, authSess.HasScope(acl.ResourceSessions.String())) + assert.True(t, authSess.ScopeAllows(acl.ResourceWebDAV, acl.Permissions{acl.ActionCreate})) + assert.False(t, authSess.ScopeAllows(acl.ResourceSessions, acl.Permissions{acl.ActionCreate})) }) t.Run("EmptyPassword", func(t *testing.T) { // Create test request form. diff --git a/internal/entity/auth_session_test.go b/internal/entity/auth_session_test.go index 0bb2e5900..a6e9285e4 100644 --- a/internal/entity/auth_session_test.go +++ b/internal/entity/auth_session_test.go @@ -544,6 +544,96 @@ func TestSession_SetAuthID(t *testing.T) { }) } +func TestSession_ScopeAllows(t *testing.T) { + t.Run("AnyScope", func(t *testing.T) { + s := &Session{ + UserName: "test", + RefID: "sessxkkcxxxz", + AuthScope: "*", + } + + assert.True(t, s.ScopeAllows("", nil)) + }) + t.Run("ReadScope", func(t *testing.T) { + s := &Session{ + UserName: "test", + RefID: "sessxkkcxxxz", + AuthScope: "read", + } + + assert.True(t, s.ScopeAllows("metrics", nil)) + assert.True(t, s.ScopeAllows("sessions", nil)) + assert.True(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionView, acl.AccessAll})) + assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) + assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) + assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionUpdate})) + assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionCreate})) + assert.False(t, s.ScopeAllows("sessions", acl.Permissions{acl.ActionDelete})) + }) + t.Run("ReadAny", func(t *testing.T) { + s := &Session{ + UserName: "test", + RefID: "sessxkkcxxxz", + AuthScope: "read *", + } + + assert.True(t, s.ScopeAllows("metrics", nil)) + assert.True(t, s.ScopeAllows("sessions", nil)) + assert.True(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionView, acl.AccessAll})) + assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) + assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) + assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionUpdate})) + assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionCreate})) + assert.False(t, s.ScopeAllows("sessions", acl.Permissions{acl.ActionDelete})) + }) + t.Run("ReadSettings", func(t *testing.T) { + s := &Session{ + UserName: "test", + RefID: "sessxkkcxxxz", + AuthScope: "read settings", + } + + assert.True(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionView})) + assert.False(t, s.ScopeAllows("metrics", nil)) + assert.False(t, s.ScopeAllows("sessions", nil)) + assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionView, acl.AccessAll})) + assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) + assert.False(t, s.ScopeAllows("metrics", acl.Permissions{acl.ActionUpdate})) + assert.False(t, s.ScopeAllows("settings", acl.Permissions{acl.ActionUpdate})) + assert.False(t, s.ScopeAllows("sessions", acl.Permissions{acl.ActionDelete})) + assert.False(t, s.ScopeAllows("sessions", acl.Permissions{acl.ActionDelete})) + }) +} + +func TestSession_ScopeExcludes(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + s := &Session{ + UserName: "test", + RefID: "sessxkkcxxxz", + AuthScope: "*", + } + + assert.False(t, s.ScopeExcludes("", nil)) + }) + t.Run("ReadSettings", func(t *testing.T) { + s := &Session{ + UserName: "test", + RefID: "sessxkkcxxxz", + AuthScope: "read settings", + } + + assert.False(t, s.ScopeExcludes("settings", acl.Permissions{acl.ActionView})) + assert.True(t, s.ScopeExcludes("metrics", nil)) + assert.True(t, s.ScopeExcludes("sessions", nil)) + assert.True(t, s.ScopeExcludes("metrics", acl.Permissions{acl.ActionView, acl.AccessAll})) + assert.True(t, s.ScopeExcludes("metrics", acl.Permissions{acl.ActionUpdate})) + assert.True(t, s.ScopeExcludes("metrics", acl.Permissions{acl.ActionUpdate})) + assert.True(t, s.ScopeExcludes("settings", acl.Permissions{acl.ActionUpdate})) + assert.True(t, s.ScopeExcludes("sessions", acl.Permissions{acl.ActionDelete})) + assert.True(t, s.ScopeExcludes("sessions", acl.Permissions{acl.ActionDelete})) + }) +} + func TestSession_SetScope(t *testing.T) { t.Run("EmptyScope", func(t *testing.T) { s := &Session{ diff --git a/internal/entity/auth_user.go b/internal/entity/auth_user.go index 4d0ced80c..fceae0f31 100644 --- a/internal/entity/auth_user.go +++ b/internal/entity/auth_user.go @@ -410,7 +410,7 @@ func (m *User) CanLogIn() bool { } else if m.IsDisabled() || m.IsUnknown() || !m.IsRegistered() { return false } else { - return acl.Resources.Allow(acl.ResourceConfig, m.AclRole(), acl.AccessOwn) + return acl.Rules.Allow(acl.ResourceConfig, m.AclRole(), acl.AccessOwn) } } @@ -425,7 +425,7 @@ func (m *User) CanUseWebDAV() bool { return false } else { // Check if the ACL allows downloading files via WebDAV based on the user role. - return acl.Resources.Allow(acl.ResourceWebDAV, m.AclRole(), acl.ActionDownload) + return acl.Rules.Allow(acl.ResourceWebDAV, m.AclRole(), acl.ActionDownload) } } @@ -439,7 +439,7 @@ func (m *User) CanUpload() bool { return false } else { // Check if the ACL allows uploading photos based on the user role. - return acl.Resources.Allow(acl.ResourcePhotos, m.AclRole(), acl.ActionUpload) + return acl.Rules.Allow(acl.ResourcePhotos, m.AclRole(), acl.ActionUpload) } } @@ -790,11 +790,11 @@ func (m *User) IsVisitor() bool { // HasSharedAccessOnly checks if the user as only access to shared resources. func (m *User) HasSharedAccessOnly(resource acl.Resource) bool { - if acl.Resources.Deny(resource, m.AclRole(), acl.AccessShared) { + if acl.Rules.Deny(resource, m.AclRole(), acl.AccessShared) { return false } - return acl.Resources.DenyAll(resource, m.AclRole(), acl.Permissions{acl.AccessAll, acl.AccessLibrary}) + return acl.Rules.DenyAll(resource, m.AclRole(), acl.Permissions{acl.AccessAll, acl.AccessLibrary}) } // IsUnknown checks if the user is unknown. diff --git a/internal/search/albums.go b/internal/search/albums.go index 4d7ab305e..08fe3b9f9 100644 --- a/internal/search/albums.go +++ b/internal/search/albums.go @@ -57,19 +57,19 @@ func UserAlbums(f form.SearchAlbums, sess *entity.Session) (results AlbumResults } // Check user permissions. - if acl.Resources.DenyAll(aclResource, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary, acl.AccessShared, acl.AccessOwn}) { + if acl.Rules.DenyAll(aclResource, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary, acl.AccessShared, acl.AccessOwn}) { return AlbumResults{}, ErrForbidden } // Limit results by UID, owner and path. if sess.IsVisitor() || sess.NotRegistered() { s = s.Where("albums.album_uid IN (?) OR albums.published_at > ?", sess.SharedUIDs(), entity.TimeStamp()) - } else if acl.Resources.DenyAll(aclResource, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { + } else if acl.Rules.DenyAll(aclResource, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { s = s.Where("albums.album_uid IN (?) OR albums.created_by = ? OR albums.published_at > ?", sess.SharedUIDs(), user.UserUID, entity.TimeStamp()) } // Exclude private content? - if acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.AccessPrivate) || acl.Resources.Deny(aclResource, aclRole, acl.AccessPrivate) { + if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.AccessPrivate) || acl.Rules.Deny(aclResource, aclRole, acl.AccessPrivate) { f.Public = true f.Private = false } diff --git a/internal/search/photos.go b/internal/search/photos.go index 2aab7bcf1..789587a80 100644 --- a/internal/search/photos.go +++ b/internal/search/photos.go @@ -129,31 +129,31 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) aclRole := user.AclRole() // Exclude private content. - if acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.AccessPrivate) { + if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.AccessPrivate) { f.Public = true f.Private = false } // Exclude archived content. - if acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) { + if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) { f.Archived = false f.Review = false } // Exclude hidden files. - if acl.Resources.Deny(acl.ResourceFiles, aclRole, acl.AccessAll) { + if acl.Rules.Deny(acl.ResourceFiles, aclRole, acl.AccessAll) { f.Hidden = false } // Visitors and other restricted users can only access shared content. if f.Scope != "" && !sess.HasShare(f.Scope) && (sess.User().HasSharedAccessOnly(acl.ResourcePhotos) || sess.NotRegistered()) || - f.Scope == "" && acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) { + f.Scope == "" && acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) { event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", "denied"}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePhotos), aclRole) return PhotoResults{}, 0, ErrForbidden } // Limit results for external users. - if f.Scope == "" && acl.Resources.DenyAll(acl.ResourcePhotos, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { + if f.Scope == "" && acl.Rules.DenyAll(acl.ResourcePhotos, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { sharedAlbums := "photos.photo_uid IN (SELECT photo_uid FROM photos_albums WHERE hidden = 0 AND missing = 0 AND album_uid IN (?)) OR " if sess.IsVisitor() || sess.NotRegistered() { diff --git a/internal/search/photos_geo.go b/internal/search/photos_geo.go index c795fcd41..274ef0c06 100644 --- a/internal/search/photos_geo.go +++ b/internal/search/photos_geo.go @@ -117,26 +117,26 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes aclRole := user.AclRole() // Exclude private content. - if acl.Resources.Deny(acl.ResourcePlaces, aclRole, acl.AccessPrivate) { + if acl.Rules.Deny(acl.ResourcePlaces, aclRole, acl.AccessPrivate) { f.Public = true f.Private = false } // Exclude archived content. - if acl.Resources.Deny(acl.ResourcePlaces, aclRole, acl.ActionDelete) { + if acl.Rules.Deny(acl.ResourcePlaces, aclRole, acl.ActionDelete) { f.Archived = false f.Review = false } // Visitors and other restricted users can only access shared content. if f.Scope != "" && !sess.HasShare(f.Scope) && (sess.User().HasSharedAccessOnly(acl.ResourcePlaces) || sess.NotRegistered()) || - f.Scope == "" && acl.Resources.Deny(acl.ResourcePlaces, aclRole, acl.ActionSearch) { + f.Scope == "" && acl.Rules.Deny(acl.ResourcePlaces, aclRole, acl.ActionSearch) { event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", "denied"}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePlaces), aclRole) return GeoResults{}, ErrForbidden } // Limit results for external users. - if f.Scope == "" && acl.Resources.DenyAll(acl.ResourcePlaces, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { + if f.Scope == "" && acl.Rules.DenyAll(acl.ResourcePlaces, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { sharedAlbums := "photos.photo_uid IN (SELECT photo_uid FROM photos_albums WHERE hidden = 0 AND missing = 0 AND album_uid IN (?)) OR " if sess.IsVisitor() || sess.NotRegistered() { diff --git a/internal/search/sessions.go b/internal/search/sessions.go index b812d57cc..47a3492ae 100644 --- a/internal/search/sessions.go +++ b/internal/search/sessions.go @@ -5,7 +5,6 @@ import ( "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/form" - "github.com/photoprism/photoprism/pkg/rnd" ) // Sessions finds user sessions. @@ -13,20 +12,24 @@ func Sessions(f form.SearchSessions) (result entity.Sessions, err error) { result = entity.Sessions{} stmt := Db() + userUid := strings.TrimSpace(f.UID) search := strings.TrimSpace(f.Query) - uid := strings.TrimSpace(f.UID) + sortOrder := f.Order limit := f.Count offset := f.Offset - if search == "all" { - // Don't filter. - } else if rnd.IsUID(uid, entity.UserUID) { - stmt = stmt.Where("user_uid = ?", search) - } else if search != "" { + // Filter by user UID? + if userUid != "" { + stmt = stmt.Where("user_uid = ?", userUid) + } + + // Filter by user name and/or auth provider name? + if search != "" && search != "all" { stmt = stmt.Where("user_name LIKE ? OR auth_provider LIKE ?", search+"%", search+"%") } + // Sort results? if sortOrder == "" { sortOrder = "last_active DESC, user_name" } diff --git a/internal/server/routes.go b/internal/server/routes.go index 3069154a0..00e2225c9 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -54,6 +54,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.UploadUserFiles(APIv1) api.ProcessUserUpload(APIv1) api.UploadUserAvatar(APIv1) + api.FindUserSessions(APIv1) api.CreateUserPasscode(APIv1) api.ConfirmUserPasscode(APIv1) api.ActivateUserPasscode(APIv1) diff --git a/internal/server/webdav_auth.go b/internal/server/webdav_auth.go index 780e56099..a1c9d36f2 100644 --- a/internal/server/webdav_auth.go +++ b/internal/server/webdav_auth.go @@ -114,7 +114,7 @@ func WebDAVAuth(conf *config.Config) gin.HandlerFunc { event.AuditErr([]string{clientIp, "session %s", "access webdav without user account", "denied"}, sess.RefID) WebDAVAbortUnauthorized(c) return - } else if sess.IsClient() && !sess.HasScope(acl.ResourceWebDAV.String()) { + } else if sess.IsClient() && sess.ScopeExcludes(acl.ResourceWebDAV, nil) { // Log error if the client is allowed to access webdav based on its scope. message := "denied" event.AuditWarn([]string{clientIp, "client %s", "session %s", "access webdav as %s", message}, clean.Log(sess.ClientInfo()), sess.RefID, clean.LogQuote(user.Username())) diff --git a/internal/server/webdav_auth_test.go b/internal/server/webdav_auth_test.go index 539f1d19f..5278e2728 100644 --- a/internal/server/webdav_auth_test.go +++ b/internal/server/webdav_auth_test.go @@ -170,7 +170,7 @@ func TestWebDAVAuthSession(t *testing.T) { assert.True(t, sess.HasUser()) assert.Equal(t, user.UserUID, sess.UserUID) assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, sess.UserUID) - assert.True(t, sess.HasScope(acl.ResourceWebDAV.String())) + assert.True(t, sess.ScopeAllows(acl.ResourceWebDAV, acl.Permissions{acl.ActionView})) assert.False(t, cached) assert.Equal(t, s.ID, sid) @@ -222,7 +222,7 @@ func TestWebDAVAuthSession(t *testing.T) { assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, user.UserUID) assert.Equal(t, entity.UserFixtures.Get("alice").UserUID, sess.UserUID) assert.True(t, user.CanUseWebDAV()) - assert.False(t, sess.HasScope(acl.ResourceWebDAV.String())) + assert.False(t, sess.ScopeAllows(acl.ResourceWebDAV, acl.Permissions{acl.ActionView})) // WebDAVAuthSession should not set a status code or any headers. assert.Equal(t, http.StatusOK, c.Writer.Status()) diff --git a/internal/server/wellknown/oauth.go b/internal/server/wellknown/oauth.go index da8d22255..0036c747e 100644 --- a/internal/server/wellknown/oauth.go +++ b/internal/server/wellknown/oauth.go @@ -45,7 +45,7 @@ func NewOAuthAuthorizationServer(conf *config.Config) *OAuthAuthorizationServer Issuer: conf.SiteUrl(), AuthorizationEndpoint: "", TokenEndpoint: fmt.Sprintf("%sapi/v1/oauth/token", conf.SiteUrl()), - ScopesSupported: acl.Resources.Resources(), + ScopesSupported: acl.Rules.Resources(), ResponseTypesSupported: OAuthResponseTypes, GrantTypesSupported: OAuthGrantTypes, TokenEndpointAuthMethodsSupported: OAuthTokenEndpointAuthMethods, diff --git a/internal/server/wellknown/openid.go b/internal/server/wellknown/openid.go index e5aafee9a..193822e84 100644 --- a/internal/server/wellknown/openid.go +++ b/internal/server/wellknown/openid.go @@ -49,7 +49,7 @@ func NewOpenIDConfiguration(conf *config.Config) *OpenIDConfiguration { GrantTypesSupported: OAuthGrantTypes, SubjectTypesSupported: []string{}, IdTokenSigningAlgValuesSupported: []string{}, - ScopesSupported: acl.Resources.Resources(), + ScopesSupported: acl.Rules.Resources(), TokenEndpointAuthMethodsSupported: OAuthTokenEndpointAuthMethods, ClaimsSupported: []string{}, CodeChallengeMethodsSupported: []string{}, diff --git a/pkg/list/attribute.go b/pkg/list/attribute.go index 76848a349..57a0ac820 100644 --- a/pkg/list/attribute.go +++ b/pkg/list/attribute.go @@ -74,7 +74,9 @@ func (f *KeyValue) Parse(s string) *KeyValue { } // Default? - if v = Value(v); v == "" { + if f.Key == All { + return f + } else if v = Value(v); v == "" { f.Value = True return f } @@ -95,6 +97,10 @@ func (f *KeyValue) String() string { return "" } + if f.Key == All { + return All + } + if Bool[strings.ToLower(f.Value)] == True { return f.Key } diff --git a/pkg/list/attributes.go b/pkg/list/attributes.go index e64a4abd8..f0b46a443 100644 --- a/pkg/list/attributes.go +++ b/pkg/list/attributes.go @@ -59,45 +59,72 @@ func (list Attr) String() string { } // Sort sorts the attributes by key. -func (list Attr) Sort() { +func (list Attr) Sort() Attr { sort.Slice(list, func(i, j int) bool { if list[i].Key == list[j].Key { return list[i].Value < list[j].Value + } else if list[i].Key == All { + return false + } else if list[j].Key == All { + return true } else { return list[i].Key < list[j].Key } }) + + return list } // Contains tests if the list contains the attribute provided as string. func (list Attr) Contains(s string) bool { - if len(list) == 0 || s == "" { + attr := list.Find(s) + + if attr.Key == "" || attr.Value == False { return false + } + + return true +} + +// Find returns the matching KeyValue attribute if found. +func (list Attr) Find(s string) (a KeyValue) { + if len(list) == 0 || s == "" { + return a } else if s == All { - return true + return KeyValue{Key: All, Value: ""} } attr := ParseKeyValue(s) - // Abort if attribute is invalid. + // Return nil if key is invalid or all. if attr.Key == "" { - return false + return a } - // Find matches. + // Find and return first match. if attr.Value == "" || attr.Value == All { for i := range list { - if strings.EqualFold(attr.Key, list[i].Key) || list[i].Key == All { - return true + if strings.EqualFold(attr.Key, list[i].Key) { + return *list[i] + } else if list[i].Key == All { + a = *list[i] } } } else { for i := range list { - if strings.EqualFold(attr.Key, list[i].Key) && (attr.Value == list[i].Value || list[i].Value == All) || list[i].Key == All { - return true + if strings.EqualFold(attr.Key, list[i].Key) { + if attr.Value == True && list[i].Value == False { + return KeyValue{Key: "", Value: ""} + } else if attr.Value == list[i].Value { + return *list[i] + } else if list[i].Value == All { + a = *list[i] + } + } else if list[i].Key == All && attr.Value != False { + a = *list[i] } } } - return false + return a } diff --git a/pkg/list/attributes_test.go b/pkg/list/attributes_test.go index a9225e926..f8b3de400 100644 --- a/pkg/list/attributes_test.go +++ b/pkg/list/attributes_test.go @@ -18,12 +18,14 @@ func TestParseAttr(t *testing.T) { assert.Len(t, attr, 3) assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, attr) + assert.Equal(t, Attr{{Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}, {Key: "foo", Value: "true"}}, attr.Sort()) }) t.Run("WhitespaceKeys", func(t *testing.T) { attr := ParseAttr(" foo bar baz ") assert.Len(t, attr, 3) assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, attr) + assert.Equal(t, Attr{{Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}, {Key: "foo", Value: "true"}}, attr.Sort()) }) t.Run("Values", func(t *testing.T) { attr := ParseAttr("foo:yes bar:disable baz:true biZZ:false BIG CAT:FISH berghain:berlin:germany hello:off") @@ -41,6 +43,18 @@ func TestParseAttr(t *testing.T) { {Key: "hello", Value: "false"}, }, attr, ) + assert.Equal(t, + Attr{ + {Key: "BIG", Value: "true"}, + {Key: "CAT", Value: "FISH"}, + {Key: "bar", Value: "false"}, + {Key: "baz", Value: "true"}, + {Key: "berghain", Value: "berlin:germany"}, + {Key: "biZZ", Value: "false"}, + {Key: "foo", Value: "true"}, + {Key: "hello", Value: "false"}, + }, attr.Sort(), + ) }) t.Run("Scopes", func(t *testing.T) { attr := ParseAttr("files files.read:true photos photos.create:false albums:true people.view:true config.view:false") @@ -55,6 +69,15 @@ func TestParseAttr(t *testing.T) { {Key: "people.view", Value: "true"}, {Key: "config.view", Value: "false"}, }, attr) + assert.Equal(t, Attr{ + {Key: "albums", Value: "true"}, + {Key: "config.view", Value: "false"}, + {Key: "files", Value: "true"}, + {Key: "files.read", Value: "true"}, + {Key: "people.view", Value: "true"}, + {Key: "photos", Value: "true"}, + {Key: "photos.create", Value: "false"}, + }, attr.Sort()) }) } @@ -63,7 +86,7 @@ func TestParseKeyValue(t *testing.T) { kv := ParseKeyValue("*") assert.Equal(t, "*", kv.Key) - assert.Equal(t, "true", kv.Value) + assert.Equal(t, "", kv.Value) }) t.Run("Scope", func(t *testing.T) { kv := ParseKeyValue("files.read:true") @@ -88,11 +111,11 @@ func TestAttr_String(t *testing.T) { assert.Equal(t, s, attr.String()) }) t.Run("Random", func(t *testing.T) { - s := " admin.conversations.removeCustomRetention admin.usergroups:read me:yes FOOt0-2U 6VU #$#%$ cm,Nu" + s := " admin.conversations.removeCustomRetention * admin.usergroups:read me:yes FOOt0-2U 6VU #$#%$ cm,Nu" attr := ParseAttr(s) - assert.Len(t, attr, 6) - assert.Equal(t, "6VU FOOt0-2U admin.conversations.removeCustomRetention admin.usergroups:read cmNu me", attr.String()) + assert.Len(t, attr, 7) + assert.Equal(t, "6VU FOOt0-2U admin.conversations.removeCustomRetention admin.usergroups:read cmNu me *", attr.String()) }) } @@ -132,3 +155,94 @@ func TestAttr_Contains(t *testing.T) { assert.True(t, attr.Contains("people.view")) }) } + +func TestAttr_Find(t *testing.T) { + t.Run("Any", func(t *testing.T) { + s := "*" + attr := ParseAttr(s) + + assert.Len(t, attr, 1) + result := attr.Find("metrics") + + assert.Equal(t, All, result.Key) + assert.Equal(t, "", result.Value) + }) + t.Run("Empty", func(t *testing.T) { + s := "*" + attr := ParseAttr(s) + + assert.Len(t, attr, 1) + result := attr.Find("") + assert.Equal(t, "", result.Key) + assert.Equal(t, "", result.Value) + }) + t.Run("All", func(t *testing.T) { + s := "*" + attr := ParseAttr(s) + + assert.Len(t, attr, 1) + result := attr.Find("*") + assert.Equal(t, All, result.Key) + assert.Equal(t, "", result.Value) + }) + t.Run("ValueAll", func(t *testing.T) { + s := "*" + attr := ParseAttr(s) + + assert.Len(t, attr, 1) + result := attr.Find("6VU:*") + assert.Equal(t, All, result.Key) + assert.Equal(t, "", result.Value) + }) + t.Run("Scopes", func(t *testing.T) { + attr := ParseAttr("files files.read:true photos photos.create:false albums:true people.view:true config.view:false") + + assert.Len(t, attr, 7) + result := attr.Find("people.view") + assert.Equal(t, "people.view", result.Key) + assert.Equal(t, True, result.Value) + }) + t.Run("ReadAll", func(t *testing.T) { + s := "read *" + attr := ParseAttr(s) + + assert.Len(t, attr, 2) + result := attr.Find("read") + assert.Equal(t, "read", result.Key) + assert.Equal(t, True, result.Value) + }) + t.Run("ReadFalse", func(t *testing.T) { + s := "read:false *" + attr := ParseAttr(s) + + assert.Len(t, attr, 2) + result := attr.Find("read:*") + assert.Equal(t, "read", result.Key) + assert.Equal(t, False, result.Value) + result = attr.Find("read:false") + assert.Equal(t, "read", result.Key) + assert.Equal(t, False, result.Value) + }) + t.Run("ReadOther", func(t *testing.T) { + s := "read:other *" + attr := ParseAttr(s) + + assert.Len(t, attr, 2) + + result := attr.Find("read") + assert.Equal(t, All, result.Key) + assert.Equal(t, "", result.Value) + + result = attr.Find("read:other") + assert.Equal(t, "read", result.Key) + assert.Equal(t, "other", result.Value) + + result = attr.Find("read:true") + assert.Equal(t, All, result.Key) + assert.Equal(t, "", result.Value) + + result = attr.Find("read:false") + assert.Equal(t, "", result.Key) + assert.Equal(t, "", result.Value) + }) +}