feat!: fine-grained permissions

This commit is contained in:
Henrique Dias
2024-07-31 11:06:34 +02:00
parent f4de82cfd1
commit b5a3d07f5c
7 changed files with 176 additions and 87 deletions
+11 -9
View File
@@ -75,8 +75,10 @@ debug: false
# Default is '.' (current directory). # Default is '.' (current directory).
directory: . directory: .
# The default modification permissions for users. Default is 'false'. # The default permissions for users. This is a case insensitive option. Possible
modify: true # permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
# permissions. For example, to allow to read and create, set "RC". Default is "R".
permissions: R
# The default permissions rules for users. Default is none. # The default permissions rules for users. Default is none.
rules: [] rules: []
@@ -120,19 +122,19 @@ users:
password: "{env}ENV_PASSWORD" password: "{env}ENV_PASSWORD"
- username: basic - username: basic
password: basic password: basic
# Override default modify. # Override default permissions.
modify: false permissions: CRUD
rules: rules:
# With this rule, the user CANNOT access /some/files. # With this rule, the user CANNOT access /some/files.
- path: /some/file - path: /some/file
allow: false permissions: none
# With this rule, the user CAN modify /public/access. # With this rule, the user CAN create, read, update and delete within /public/access.
- path: /public/access/ - path: /public/access/
modify: true permissions: CRUD
# With this rule, the user CAN modify all files ending with .js. It uses # With this rule, the user CAN read and update all files ending with .js. It uses
# a regular expression. # a regular expression.
- regex: "^.+.js$" - regex: "^.+.js$"
modify: true permissions: RU
``` ```
### CORS ### CORS
+16 -16
View File
@@ -23,18 +23,18 @@ const (
) )
type Config struct { type Config struct {
Permissions `mapstructure:",squash"` UserPermissions `mapstructure:",squash"`
Debug bool Debug bool
Address string Address string
Port int Port int
TLS bool TLS bool
Cert string Cert string
Key string Key string
Prefix string Prefix string
NoSniff bool NoSniff bool
Log Log Log Log
CORS CORS CORS CORS
Users []User Users []User
} }
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
@@ -74,7 +74,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
// Other defaults // Other defaults
v.SetDefault("Directory", ".") v.SetDefault("Directory", ".")
v.SetDefault("Modify", false) v.SetDefault("Permissions", "R")
v.SetDefault("Debug", false) v.SetDefault("Debug", false)
v.SetDefault("NoSniff", false) v.SetDefault("NoSniff", false)
v.SetDefault("Log.Format", "console") v.SetDefault("Log.Format", "console")
@@ -108,8 +108,8 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
cfg.Users[i].Directory = cfg.Directory cfg.Users[i].Directory = cfg.Directory
} }
if !v.IsSet(fmt.Sprintf("Users.%d.Modify", i)) { if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
cfg.Users[i].Modify = cfg.Modify cfg.Users[i].Permissions = cfg.Permissions
} }
if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) { if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
@@ -153,7 +153,7 @@ func (c *Config) Validate() error {
} }
} }
err = c.Permissions.Validate() err = c.UserPermissions.Validate()
if err != nil { if err != nil {
return fmt.Errorf("invalid config: %w", err) return fmt.Errorf("invalid config: %w", err)
} }
+28 -20
View File
@@ -49,17 +49,25 @@ func TestConfigCascade(t *testing.T) {
t.Parallel() t.Parallel()
check := func(t *testing.T, cfg *Config) { check := func(t *testing.T, cfg *Config) {
require.True(t, cfg.Modify) require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
require.False(t, cfg.Permissions.Delete)
require.False(t, cfg.Permissions.Update)
require.Equal(t, "/", cfg.Directory) require.Equal(t, "/", cfg.Directory)
require.Len(t, cfg.Rules, 1) require.Len(t, cfg.Rules, 1)
require.Len(t, cfg.Users, 2) require.Len(t, cfg.Users, 2)
require.True(t, cfg.Users[0].Permissions.Read)
require.True(t, cfg.Users[0].Modify) require.True(t, cfg.Users[0].Permissions.Create)
require.False(t, cfg.Users[0].Permissions.Delete)
require.False(t, cfg.Users[0].Permissions.Update)
require.Equal(t, "/", cfg.Users[0].Directory) require.Equal(t, "/", cfg.Users[0].Directory)
require.Len(t, cfg.Users[0].Rules, 1) require.Len(t, cfg.Users[0].Rules, 1)
require.False(t, cfg.Users[1].Modify) require.True(t, cfg.Users[1].Permissions.Read)
require.False(t, cfg.Users[1].Permissions.Create)
require.False(t, cfg.Users[1].Permissions.Delete)
require.False(t, cfg.Users[1].Permissions.Update)
require.Equal(t, "/basic", cfg.Users[1].Directory) require.Equal(t, "/basic", cfg.Users[1].Directory)
require.Len(t, cfg.Users[1].Rules, 0) require.Len(t, cfg.Users[1].Rules, 0)
} }
@@ -67,10 +75,10 @@ func TestConfigCascade(t *testing.T) {
t.Run("YAML", func(t *testing.T) { t.Run("YAML", func(t *testing.T) {
content := ` content := `
directory: / directory: /
modify: true permissions: CR
rules: rules:
- path: /public/access/ - path: /public/access/
modify: true permissions: R
users: users:
- username: admin - username: admin
@@ -78,7 +86,7 @@ users:
- username: basic - username: basic
password: basic password: basic
directory: /basic directory: /basic
modify: false permissions: R
rules: []` rules: []`
cfg := writeAndParseConfig(t, content, ".yml") cfg := writeAndParseConfig(t, content, ".yml")
@@ -90,11 +98,11 @@ users:
t.Run("JSON", func(t *testing.T) { t.Run("JSON", func(t *testing.T) {
content := `{ content := `{
"directory": "/", "directory": "/",
"modify": true, "permissions": "CR",
"rules": [ "rules": [
{ {
"path": "/public/access/", "path": "/public/access/",
"modify": true "permissions": "R"
} }
], ],
"users": [ "users": [
@@ -106,7 +114,7 @@ users:
"username": "basic", "username": "basic",
"password": "basic", "password": "basic",
"directory": "/basic", "directory": "/basic",
"modify": false, "permissions": "R",
"rules": [] "rules": []
} }
] ]
@@ -121,11 +129,11 @@ users:
t.Run("`TOML", func(t *testing.T) { t.Run("`TOML", func(t *testing.T) {
content := ` content := `
directory = "/" directory = "/"
modify = true permissions = "CR"
[[rules]] [[rules]]
path = "/public/access/" path = "/public/access/"
modify = true permissions = "R"
[[users]] [[users]]
username = "admin" username = "admin"
@@ -135,7 +143,7 @@ password = "admin"
username = "basic" username = "basic"
password = "basic" password = "basic"
directory = "/basic" directory = "/basic"
modify = false permissions = "R"
rules = [] rules = []
` `
@@ -175,12 +183,9 @@ cors:
func TestConfigRules(t *testing.T) { func TestConfigRules(t *testing.T) {
content := ` content := `
directory: / directory: /
modify: true
rules: rules:
- regex: '^.+\.js$' - regex: '^.+\.js$'
modify: true - path: /public/access/`
- path: /public/access/
modify: true`
cfg := writeAndParseConfig(t, content, ".yaml") cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate()) require.NoError(t, cfg.Validate())
@@ -199,7 +204,7 @@ rules:
func TestConfigEnv(t *testing.T) { func TestConfigEnv(t *testing.T) {
require.NoError(t, os.Setenv("WD_PORT", "1234")) require.NoError(t, os.Setenv("WD_PORT", "1234"))
require.NoError(t, os.Setenv("WD_DEBUG", "true")) require.NoError(t, os.Setenv("WD_DEBUG", "true"))
require.NoError(t, os.Setenv("WD_MODIFY", "true")) require.NoError(t, os.Setenv("WD_PERMISSIONS", "CRUD"))
require.NoError(t, os.Setenv("WD_DIRECTORY", "/test")) require.NoError(t, os.Setenv("WD_DIRECTORY", "/test"))
cfg, err := ParseConfig("", nil) cfg, err := ParseConfig("", nil)
@@ -208,11 +213,14 @@ func TestConfigEnv(t *testing.T) {
assert.Equal(t, 1234, cfg.Port) assert.Equal(t, 1234, cfg.Port)
assert.Equal(t, "/test", cfg.Directory) assert.Equal(t, "/test", cfg.Directory)
assert.Equal(t, true, cfg.Debug) assert.Equal(t, true, cfg.Debug)
assert.Equal(t, true, cfg.Modify) require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
require.True(t, cfg.Permissions.Delete)
require.True(t, cfg.Permissions.Update)
// Reset // Reset
require.NoError(t, os.Setenv("WD_PORT", "")) require.NoError(t, os.Setenv("WD_PORT", ""))
require.NoError(t, os.Setenv("WD_DEBUG", "")) require.NoError(t, os.Setenv("WD_DEBUG", ""))
require.NoError(t, os.Setenv("WD_MODIFY", "")) require.NoError(t, os.Setenv("WD_PERMISSIONS", ""))
require.NoError(t, os.Setenv("WD_DIRECTORY", "")) require.NoError(t, os.Setenv("WD_DIRECTORY", ""))
} }
+12 -2
View File
@@ -2,6 +2,8 @@ package lib
import ( import (
"net/http" "net/http"
"net/url"
"os"
"strings" "strings"
"github.com/rs/cors" "github.com/rs/cors"
@@ -23,7 +25,7 @@ func NewHandler(c *Config) (http.Handler, error) {
h := &Handler{ h := &Handler{
user: &handlerUser{ user: &handlerUser{
User: User{ User: User{
Permissions: c.Permissions, UserPermissions: c.UserPermissions,
}, },
Handler: webdav.Handler{ Handler: webdav.Handler{
Prefix: c.Prefix, Prefix: c.Prefix,
@@ -100,7 +102,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
// Checks for user permissions relatively to this PATH. // Checks for user permissions relatively to this PATH.
allowed := user.Allowed(r) allowed := user.Allowed(r, func(destination string) bool {
u, err := url.Parse(destination)
if err != nil {
return false
}
path := strings.TrimPrefix(u.Path, user.Prefix)
_, err = user.FileSystem.Stat(r.Context(), path)
return !os.IsNotExist(err)
})
zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path)) zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
+33 -8
View File
@@ -133,7 +133,7 @@ func TestServerAuthentication(t *testing.T) {
srv := makeTestServer(t, fmt.Sprintf(` srv := makeTestServer(t, fmt.Sprintf(`
directory: %s directory: %s
modify: true permissions: CRUD
users: users:
- username: basic - username: basic
@@ -191,39 +191,58 @@ func TestServerRules(t *testing.T) {
"a/foo.js": []byte("foo js"), "a/foo.js": []byte("foo js"),
"a/foo.txt": []byte("foo txt"), "a/foo.txt": []byte("foo txt"),
"b/foo.txt": []byte("foo b"), "b/foo.txt": []byte("foo b"),
"c/a.txt": []byte("b"),
"c/b.txt": []byte("b"),
"c/c.txt": []byte("b"),
}) })
srv := makeTestServer(t, fmt.Sprintf(` srv := makeTestServer(t, fmt.Sprintf(`
directory: %s directory: %s
modify: true permissions: CRUD
users: users:
- username: basic - username: basic
password: basic password: basic
rules: rules:
- regex: "^.+.js$" - regex: "^.+.js$"
modify: false permissions: R
- path: "/b" - path: "/b"
modify: false permissions: R
- path: "/a/foo.txt"
permissions: none
- path: "/c"
permissions: none
`, dir)) `, dir))
client := gowebdav.NewClient(srv.URL, "basic", "basic") client := gowebdav.NewClient(srv.URL, "basic", "basic")
files, err := client.ReadDir("/") files, err := client.ReadDir("/")
require.NoError(t, err) require.NoError(t, err)
require.Len(t, files, 3) require.Len(t, files, 4)
err = client.Write("/foo.txt", []byte("new"), 0666) err = client.Write("/foo.txt", []byte("new"), 0666)
require.NoError(t, err) require.NoError(t, err)
err = client.Write("/a/foo.txt", []byte("new"), 0666) err = client.Write("/new.txt", []byte("new"), 0666)
require.NoError(t, err) require.NoError(t, err)
_, err = client.Read("/a/foo.txt")
require.ErrorContains(t, err, "403")
err = client.Write("/a/foo.js", []byte("new"), 0666) err = client.Write("/a/foo.js", []byte("new"), 0666)
require.ErrorContains(t, err, "403") require.ErrorContains(t, err, "403")
err = client.Write("/b/foo.txt", []byte("new"), 0666) err = client.Write("/b/foo.txt", []byte("new"), 0666)
require.ErrorContains(t, err, "403") require.ErrorContains(t, err, "403")
_, err = client.ReadDir("/c")
require.ErrorContains(t, err, "403")
_, err = client.Read("/c/a.txt")
require.ErrorContains(t, err, "403")
err = client.Write("/c/b.txt", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
} }
func TestServerPermissions(t *testing.T) { func TestServerPermissions(t *testing.T) {
@@ -237,7 +256,7 @@ func TestServerPermissions(t *testing.T) {
srv := makeTestServer(t, fmt.Sprintf(` srv := makeTestServer(t, fmt.Sprintf(`
directory: %s directory: %s
modify: true permissions: CR
users: users:
- username: a - username: a
@@ -246,7 +265,7 @@ users:
- username: b - username: b
password: b password: b
directory: %s/b directory: %s/b
modify: false permissions: R
`, dir, dir, dir)) `, dir, dir, dir))
t.Run("User A", func(t *testing.T) { t.Run("User A", func(t *testing.T) {
@@ -265,6 +284,12 @@ users:
err = client.Copy("/foo.txt", "/copy.txt", false) err = client.Copy("/foo.txt", "/copy.txt", false)
require.NoError(t, err) require.NoError(t, err)
err = client.Copy("/foo.txt", "/copy.txt", true)
require.ErrorContains(t, err, "403")
err = client.Rename("/foo.txt", "/copy.txt", true)
require.ErrorContains(t, err, "403")
data, err = client.Read("/copy.txt") data, err = client.Read("/copy.txt")
require.NoError(t, err) require.NoError(t, err)
require.EqualValues(t, []byte("foo a"), data) require.EqualValues(t, []byte("foo a"), data)
+72 -28
View File
@@ -9,18 +9,10 @@ import (
"strings" "strings"
) )
var readMethods = []string{
http.MethodGet,
http.MethodHead,
http.MethodOptions,
"PROPFIND",
}
type Rule struct { type Rule struct {
Allow bool Permissions Permissions
Modify bool Path string
Path string Regex *regexp.Regexp
Regex *regexp.Regexp
} }
func (r *Rule) Validate() error { func (r *Rule) Validate() error {
@@ -40,36 +32,27 @@ func (r *Rule) Matches(path string) bool {
return strings.HasPrefix(path, r.Path) return strings.HasPrefix(path, r.Path)
} }
type Permissions struct { type UserPermissions struct {
Directory string Directory string
Modify bool Permissions Permissions
Rules []*Rule Rules []*Rule
} }
// Allowed checks if the user has permission to access a directory/file // Allowed checks if the user has permission to access a directory/file
func (p Permissions) Allowed(r *http.Request) bool { func (p UserPermissions) Allowed(r *http.Request, destinationExists func(string) bool) bool {
// Determine whether or not it is a read or write request.
readRequest := false
for _, method := range readMethods {
if r.Method == method {
readRequest = true
break
}
}
// Go through rules beginning from the last one. // Go through rules beginning from the last one.
for i := len(p.Rules) - 1; i >= 0; i-- { for i := len(p.Rules) - 1; i >= 0; i-- {
rule := p.Rules[i] rule := p.Rules[i]
if rule.Matches(r.URL.Path) { if rule.Matches(r.URL.Path) {
return rule.Allow && (readRequest || rule.Modify) return rule.Permissions.Allowed(r, destinationExists)
} }
} }
return readRequest || p.Modify return p.Permissions.Allowed(r, destinationExists)
} }
func (p *Permissions) Validate() error { func (p *UserPermissions) Validate() error {
var err error var err error
p.Directory, err = filepath.Abs(p.Directory) p.Directory, err = filepath.Abs(p.Directory)
@@ -85,3 +68,64 @@ func (p *Permissions) Validate() error {
return nil return nil
} }
type Permissions struct {
Create bool
Read bool
Update bool
Delete bool
}
func (p *Permissions) UnmarshalText(data []byte) error {
text := strings.ToLower(string(data))
if text == "none" {
return nil
}
for _, c := range text {
switch c {
case 'c':
p.Create = true
case 'r':
p.Read = true
case 'u':
p.Update = true
case 'd':
p.Delete = true
default:
return fmt.Errorf("invalid permission: %q", c)
}
}
return nil
}
func (p Permissions) Allowed(r *http.Request, destinationExists func(string) bool) bool {
switch r.Method {
case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND":
// Note: POST backend implementation just returns the same thing as GET.
return p.Read
case "MKCOL":
return p.Create
case "PROPPATCH":
return p.Update
case "PUT":
if destinationExists(r.URL.Path) {
return p.Update
} else {
return p.Create
}
case "COPY", "MOVE":
if destinationExists(r.Header.Get("Destination")) {
return p.Update
} else {
return p.Create
}
case "DELETE":
return p.Delete
case "LOCK", "UNLOCK":
return p.Create || p.Read || p.Update || p.Delete
default:
return false
}
}
+4 -4
View File
@@ -10,9 +10,9 @@ import (
) )
type User struct { type User struct {
Permissions `mapstructure:",squash"` UserPermissions `mapstructure:",squash"`
Username string Username string
Password string Password string
} }
func (u User) checkPassword(input string) bool { func (u User) checkPassword(input string) bool {
@@ -44,7 +44,7 @@ func (u *User) Validate() error {
} }
} }
if err := u.Permissions.Validate(); err != nil { if err := u.UserPermissions.Validate(); err != nil {
return fmt.Errorf("invalid user %q: %w", u.Username, err) return fmt.Errorf("invalid user %q: %w", u.Username, err)
} }