Skip to content

Commit 2726603

Browse files
authored
Merge pull request #41 from Dewberry/feature-read-restriction
Feature read restriction
2 parents 47ab4ad + dc2feb9 commit 2726603

16 files changed

Lines changed: 652 additions & 486 deletions

.example.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ S3API_SERVICE_PORT='5005'
66
KEYCLOAK_PUBLIC_KEYS_URL='public-keys-url-string'
77
AUTH_LEVEL=1 # Options: [0, 1] corresponds to [no FGAC, FGAC]. This integer value configures the initialization mode in docker-compose.
88
AUTH_LIMITED_WRITER_ROLE='s3_limited_writer'
9+
AUTH_LIMITED_READER_ROLE='s3_limited_reader'
910

1011
## DB for Auth:
1112
POSTGRES_CONN_STRING='postgres://user:password@postgres:5432/db?sslmode=disable'

auth/database.go

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,22 @@ import (
66
"os"
77

88
"github.com/labstack/gommon/log"
9+
"github.com/lib/pq"
910
_ "github.com/lib/pq"
1011
)
1112

1213
// Database interface abstracts database operations
1314
type Database interface {
14-
CheckUserPermission(userEmail, operation, s3_prefix string) bool
15+
CheckUserPermission(userEmail, bucket, prefix string, operations []string) bool
1516
Close() error
17+
GetUserAccessiblePrefixes(userEmail, bucket string, operations []string) ([]string, error)
1618
}
1719

1820
type PostgresDB struct {
1921
Handle *sql.DB
2022
}
2123

22-
// Initialize the database and create tables if they do not exist.
24+
// NewPostgresDB initializes the database and creates tables if they do not exist.
2325
func NewPostgresDB() (*PostgresDB, error) {
2426
connString, exist := os.LookupEnv("POSTGRES_CONN_STRING")
2527
if !exist {
@@ -41,7 +43,7 @@ func NewPostgresDB() (*PostgresDB, error) {
4143
return pgDB, nil
4244
}
4345

44-
// Creates the necessary tables in the database.
46+
// createTables creates the necessary tables in the database.
4547
func (db *PostgresDB) createTables() error {
4648
createPermissionsTable := `
4749
CREATE TABLE IF NOT EXISTS permissions (
@@ -63,21 +65,57 @@ func (db *PostgresDB) createTables() error {
6365
return nil
6466
}
6567

68+
// GetUserAccessiblePrefixes retrieves the accessible prefixes for a user.
69+
func (db *PostgresDB) GetUserAccessiblePrefixes(userEmail, bucket string, operations []string) ([]string, error) {
70+
query := `
71+
WITH unnested_permissions AS (
72+
SELECT DISTINCT unnest(allowed_s3_prefixes) AS allowed_prefix
73+
FROM permissions
74+
WHERE user_email = $1 AND operation = ANY($3)
75+
)
76+
SELECT allowed_prefix
77+
FROM unnested_permissions
78+
WHERE allowed_prefix LIKE $2 || '/%'
79+
ORDER BY allowed_prefix;
80+
`
81+
82+
rows, err := db.Handle.Query(query, userEmail, "/"+bucket, pq.Array(operations))
83+
if err != nil {
84+
return nil, fmt.Errorf("database error: %s", err)
85+
}
86+
defer rows.Close()
87+
88+
var prefixes []string
89+
var prefix string
90+
for rows.Next() {
91+
if err := rows.Scan(&prefix); err != nil {
92+
return nil, fmt.Errorf("scan error: %s", err)
93+
}
94+
prefixes = append(prefixes, prefix)
95+
}
96+
if err = rows.Err(); err != nil {
97+
return nil, fmt.Errorf("row error: %s", err)
98+
}
99+
100+
return prefixes, nil
101+
}
102+
66103
// CheckUserPermission checks if a user has permission for a specific request.
67-
func (db *PostgresDB) CheckUserPermission(userEmail, operation, s3_prefix string) bool {
104+
func (db *PostgresDB) CheckUserPermission(userEmail, bucket, prefix string, operations []string) bool {
105+
s3Prefix := fmt.Sprintf("/%s/%s", bucket, prefix)
68106
query := `
69107
SELECT EXISTS (
70108
SELECT 1
71109
FROM permissions,
72110
UNNEST(allowed_s3_prefixes) AS allowed_prefix
73111
WHERE user_email = $1
74-
AND operation = $2
112+
AND operation = ANY($2)
75113
AND $3 LIKE allowed_prefix || '%'
76114
);
77115
`
78116

79117
var hasPermission bool
80-
if err := db.Handle.QueryRow(query, userEmail, operation, s3_prefix).Scan(&hasPermission); err != nil {
118+
if err := db.Handle.QueryRow(query, userEmail, pq.Array(operations), s3Prefix).Scan(&hasPermission); err != nil {
81119
log.Errorf("error querying user permissions: %v", err)
82120
return false
83121
}

blobstore/blobhandler.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http"
66
"os"
77
"strconv"
8+
"strings"
89
"sync"
910

1011
"github.com/Dewberry/s3api/auth"
@@ -30,6 +31,7 @@ type Config struct {
3031
// external sources like configuration files, environment variables should go here.
3132
AuthLevel int
3233
LimitedWriterRoleName string
34+
LimitedReaderRoleName string
3335
DefaultTempPrefix string
3436
DefaultDownloadPresignedUrlExpiration int
3537
DefaultUploadPresignedUrlExpiration int
@@ -305,3 +307,50 @@ func (bh *BlobHandler) PingWithAuth(c echo.Context) error {
305307

306308
return c.JSON(http.StatusOK, bucketHealth)
307309
}
310+
311+
func (bh *BlobHandler) GetS3ReadPermissions(c echo.Context, bucket string) ([]string, bool, int, error) {
312+
permissions, fullAccess, err := bh.GetUserS3ReadListPermission(c, bucket)
313+
if err != nil {
314+
//TEMP solution before error library is implimented and string check ups become redundant
315+
httpStatus := http.StatusInternalServerError
316+
if strings.Contains(err.Error(), "this endpoint requires authentication information that is unavailable when authorization is disabled.") {
317+
httpStatus = http.StatusForbidden
318+
}
319+
return nil, false, httpStatus, fmt.Errorf("error fetching user permissions: %s", err.Error())
320+
}
321+
if !fullAccess && len(permissions) == 0 {
322+
return nil, false, http.StatusForbidden, fmt.Errorf("user does not have permission to read the %s bucket", bucket)
323+
}
324+
return permissions, fullAccess, http.StatusOK, nil
325+
}
326+
327+
func (bh *BlobHandler) HandleCheckS3UserPermission(c echo.Context) error {
328+
if bh.Config.AuthLevel == 0 {
329+
log.Info("Checked user permissions successfully")
330+
return c.JSON(http.StatusOK, true)
331+
}
332+
initAuth := os.Getenv("INIT_AUTH")
333+
if initAuth == "0" {
334+
errMsg := fmt.Errorf("this endpoint requires authentication information that is unavailable when authorization is disabled. Please enable authorization to use this functionality")
335+
log.Error(errMsg.Error())
336+
return c.JSON(http.StatusForbidden, errMsg.Error())
337+
}
338+
prefix := c.QueryParam("prefix")
339+
bucket := c.QueryParam("bucket")
340+
operation := c.QueryParam("operation")
341+
claims, ok := c.Get("claims").(*auth.Claims)
342+
if !ok {
343+
errMsg := fmt.Errorf("could not get claims from request context")
344+
log.Error(errMsg.Error())
345+
return c.JSON(http.StatusInternalServerError, errMsg.Error())
346+
}
347+
userEmail := claims.Email
348+
if operation == "" || prefix == "" || bucket == "" {
349+
errMsg := fmt.Errorf("`prefix`, `operation` and 'bucket are required params")
350+
log.Error(errMsg.Error())
351+
return c.JSON(http.StatusUnprocessableEntity, errMsg.Error())
352+
}
353+
isAllowed := bh.DB.CheckUserPermission(userEmail, bucket, prefix, []string{operation})
354+
log.Info("Checked user permissions successfully")
355+
return c.JSON(http.StatusOK, isAllowed)
356+
}

blobstore/blobstore.go

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ package blobstore
33
import (
44
"fmt"
55
"net/http"
6-
"time"
6+
"os"
7+
"strings"
78

89
"github.com/Dewberry/s3api/auth"
910
"github.com/Dewberry/s3api/utils"
@@ -14,6 +15,7 @@ import (
1415
)
1516

1617
func (s3Ctrl *S3Controller) KeyExists(bucket string, key string) (bool, error) {
18+
1719
_, err := s3Ctrl.S3Svc.HeadObject(&s3.HeadObjectInput{
1820
Bucket: aws.String(bucket),
1921
Key: aws.String(key),
@@ -33,23 +35,23 @@ func (s3Ctrl *S3Controller) KeyExists(bucket string, key string) (bool, error) {
3335
}
3436

3537
// function that will get the most recently uploaded file in a prefix
36-
func (s3Ctrl *S3Controller) getMostRecentModTime(bucket, prefix string) (time.Time, error) {
37-
// Initialize a time variable to store the most recent modification time
38-
var mostRecent time.Time
38+
// func (s3Ctrl *S3Controller) getMostRecentModTime(bucket, prefix string, permissions []string, fullAccess bool) (time.Time, error) {
39+
// // Initialize a time variable to store the most recent modification time
40+
// var mostRecent time.Time
3941

40-
// Call GetList to retrieve the list of objects with the specified prefix
41-
response, err := s3Ctrl.GetList(bucket, prefix, false)
42-
if err != nil {
43-
return time.Time{}, err
44-
}
45-
// Iterate over the returned objects to find the most recent modification time
46-
for _, item := range response.Contents {
47-
if item.LastModified != nil && item.LastModified.After(mostRecent) {
48-
mostRecent = *item.LastModified
49-
}
50-
}
51-
return mostRecent, nil
52-
}
42+
// // Call GetList to retrieve the list of objects with the specified prefix
43+
// response, err := s3Ctrl.GetList(bucket, prefix, false)
44+
// if err != nil {
45+
// return time.Time{}, err
46+
// }
47+
// // Iterate over the returned objects to find the most recent modification time
48+
// for _, item := range response.Contents {
49+
// if item.LastModified != nil && item.LastModified.After(mostRecent) {
50+
// mostRecent = *item.LastModified
51+
// }
52+
// }
53+
// return mostRecent, nil
54+
// }
5355

5456
func arrayContains(a string, arr []string) bool {
5557
for _, b := range arr {
@@ -80,8 +82,13 @@ func isIdenticalArray(array1, array2 []string) bool {
8082
return true
8183
}
8284

83-
func (bh *BlobHandler) CheckUserS3WritePermission(c echo.Context, bucket, key string) (int, error) {
85+
func (bh *BlobHandler) CheckUserS3Permission(c echo.Context, bucket, prefix string, permissions []string) (int, error) {
8486
if bh.Config.AuthLevel > 0 {
87+
initAuth := os.Getenv("INIT_AUTH")
88+
if initAuth == "0" {
89+
errMsg := fmt.Errorf("this requires authentication information that is unavailable when authorization is disabled. Please enable authorization to use this functionality")
90+
return http.StatusForbidden, errMsg
91+
}
8592
claims, ok := c.Get("claims").(*auth.Claims)
8693
if !ok {
8794
return http.StatusInternalServerError, fmt.Errorf("could not get claims from request context")
@@ -91,13 +98,54 @@ func (bh *BlobHandler) CheckUserS3WritePermission(c echo.Context, bucket, key st
9198

9299
// Check for required roles
93100
isLimitedWriter := utils.StringInSlice(bh.Config.LimitedWriterRoleName, roles)
101+
// Ensure the prefix ends with a slash
102+
if !strings.HasSuffix(prefix, "/") {
103+
prefix += "/"
104+
}
94105

95106
// We assume if someone is limited_writer, they should never be admin or super_writer
96107
if isLimitedWriter {
97-
if !bh.DB.CheckUserPermission(ue, "write", fmt.Sprintf("/%s/%s", bucket, key)) {
108+
if !bh.DB.CheckUserPermission(ue, bucket, prefix, permissions) {
98109
return http.StatusForbidden, fmt.Errorf("forbidden")
99110
}
100111
}
101112
}
102113
return 0, nil
103114
}
115+
116+
func (bh *BlobHandler) GetUserS3ReadListPermission(c echo.Context, bucket string) ([]string, bool, error) {
117+
permissions := make([]string, 0)
118+
119+
if bh.Config.AuthLevel > 0 {
120+
initAuth := os.Getenv("INIT_AUTH")
121+
if initAuth == "0" {
122+
errMsg := fmt.Errorf("this endpoint requires authentication information that is unavailable when authorization is disabled. Please enable authorization to use this functionality")
123+
return permissions, false, errMsg
124+
}
125+
fullAccess := false
126+
claims, ok := c.Get("claims").(*auth.Claims)
127+
if !ok {
128+
return permissions, fullAccess, fmt.Errorf("could not get claims from request context")
129+
}
130+
roles := claims.RealmAccess["roles"]
131+
132+
// Check if user has the limited reader role
133+
isLimitedReader := utils.StringInSlice(bh.Config.LimitedReaderRoleName, roles)
134+
135+
// If user is not a limited reader, assume they have full read access
136+
if !isLimitedReader {
137+
fullAccess = true // Indicating full access
138+
return permissions, fullAccess, nil
139+
}
140+
141+
// If user is a limited reader, fetch specific permissions
142+
ue := claims.Email
143+
permissions, err := bh.DB.GetUserAccessiblePrefixes(ue, bucket, []string{"read", "write"})
144+
if err != nil {
145+
return permissions, fullAccess, err
146+
}
147+
return permissions, fullAccess, nil
148+
}
149+
150+
return permissions, true, nil
151+
}

blobstore/buckets.go

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package blobstore
55
import (
66
"fmt"
77
"net/http"
8+
"sort"
89

910
"github.com/aws/aws-sdk-go/service/s3"
1011
"github.com/labstack/echo/v4"
@@ -75,17 +76,26 @@ func (s3Ctrl *S3Controller) ListBuckets() (*s3.ListBucketsOutput, error) {
7576
// }
7677

7778
type BucketInfo struct {
78-
ID int `json:"id"`
79-
Name string `json:"name"`
79+
ID int `json:"id"`
80+
Name string `json:"name"`
81+
CanRead bool `json:"can_read"`
8082
}
8183

8284
func (bh *BlobHandler) HandleListBuckets(c echo.Context) error {
8385
var allBuckets []BucketInfo
84-
currentID := 1 // Initialize ID counter
86+
8587
bh.Mu.Lock()
86-
for i := 0; i < len(bh.S3Controllers); i++ {
88+
defer bh.Mu.Unlock()
89+
90+
// Check user's overall read access level
91+
_, fullAccess, err := bh.GetUserS3ReadListPermission(c, "")
92+
if err != nil {
93+
return c.JSON(http.StatusInternalServerError, fmt.Errorf("error fetching user permissions: %s", err.Error()))
94+
}
95+
96+
for _, controller := range bh.S3Controllers {
8797
if bh.AllowAllBuckets {
88-
result, err := bh.S3Controllers[i].ListBuckets()
98+
result, err := controller.ListBuckets()
8999
if err != nil {
90100
errMsg := fmt.Errorf("error returning list of buckets, error: %s", err)
91101
log.Error(errMsg)
@@ -95,24 +105,39 @@ func (bh *BlobHandler) HandleListBuckets(c echo.Context) error {
95105
for _, b := range result.Buckets {
96106
mostRecentBucketList = append(mostRecentBucketList, *b.Name)
97107
}
98-
if !isIdenticalArray(bh.S3Controllers[i].Buckets, mostRecentBucketList) {
99-
100-
bh.S3Controllers[i].Buckets = mostRecentBucketList
101-
108+
if !isIdenticalArray(controller.Buckets, mostRecentBucketList) {
109+
controller.Buckets = mostRecentBucketList
102110
}
103111
}
112+
104113
// Extract the bucket names from the response and append to allBuckets
105-
for _, bucket := range bh.S3Controllers[i].Buckets {
114+
for i, bucket := range controller.Buckets {
115+
canRead := fullAccess
116+
if !fullAccess {
117+
permissions, _, err := bh.GetUserS3ReadListPermission(c, bucket)
118+
if err != nil {
119+
return c.JSON(http.StatusInternalServerError, fmt.Errorf("error fetching user permissions: %s", err.Error()))
120+
}
121+
canRead = len(permissions) > 0
122+
}
106123
allBuckets = append(allBuckets, BucketInfo{
107-
ID: currentID,
108-
Name: bucket,
124+
ID: i,
125+
Name: bucket,
126+
CanRead: canRead,
109127
})
110-
currentID++ // Increment the ID for the next bucket
111-
112128
}
113129
}
114-
bh.Mu.Unlock()
130+
131+
// Sorting allBuckets slice by CanRead true first and then by Name field alphabetically
132+
sort.Slice(allBuckets, func(i, j int) bool {
133+
if allBuckets[i].CanRead == allBuckets[j].CanRead {
134+
return allBuckets[i].Name < allBuckets[j].Name
135+
}
136+
return allBuckets[i].CanRead && !allBuckets[j].CanRead
137+
})
138+
115139
log.Info("Successfully retrieved list of buckets")
140+
116141
return c.JSON(http.StatusOK, allBuckets)
117142
}
118143

blobstore/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func newConfig(authLvl int) *Config {
2020
c := &Config{
2121
AuthLevel: authLvl,
2222
LimitedWriterRoleName: os.Getenv("AUTH_LIMITED_WRITER_ROLE"),
23+
LimitedReaderRoleName: os.Getenv("AUTH_LIMITED_READER_ROLE"),
2324
DefaultTempPrefix: getEnvOrDefault("TEMP_PREFIX", defaultTempPrefix),
2425
DefaultDownloadPresignedUrlExpiration: getIntEnvOrDefault("DOWNLOAD_URL_EXP_DAYS", defaultDownloadPresignedUrlExpiration),
2526
DefaultUploadPresignedUrlExpiration: getIntEnvOrDefault("UPLOAD_URL_EXP_MIN", defaultUploadPresignedUrlExpiration),

0 commit comments

Comments
 (0)