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).
directory: .
# The default modification permissions for users. Default is 'false'.
modify: true
# The default permissions for users. This is a case insensitive option. Possible
# 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.
rules: []
@@ -120,19 +122,19 @@ users:
password: "{env}ENV_PASSWORD"
- username: basic
password: basic
# Override default modify.
modify: false
# Override default permissions.
permissions: CRUD
rules:
# With this rule, the user CANNOT access /some/files.
- path: /some/file
allow: false
# With this rule, the user CAN modify /public/access.
permissions: none
# With this rule, the user CAN create, read, update and delete within /public/access.
- path: /public/access/
modify: true
# With this rule, the user CAN modify all files ending with .js. It uses
permissions: CRUD
# With this rule, the user CAN read and update all files ending with .js. It uses
# a regular expression.
- regex: "^.+.js$"
modify: true
permissions: RU
```
### CORS
+5 -5
View File
@@ -23,7 +23,7 @@ const (
)
type Config struct {
Permissions `mapstructure:",squash"`
UserPermissions `mapstructure:",squash"`
Debug bool
Address string
Port int
@@ -74,7 +74,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
// Other defaults
v.SetDefault("Directory", ".")
v.SetDefault("Modify", false)
v.SetDefault("Permissions", "R")
v.SetDefault("Debug", false)
v.SetDefault("NoSniff", false)
v.SetDefault("Log.Format", "console")
@@ -108,8 +108,8 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
cfg.Users[i].Directory = cfg.Directory
}
if !v.IsSet(fmt.Sprintf("Users.%d.Modify", i)) {
cfg.Users[i].Modify = cfg.Modify
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
cfg.Users[i].Permissions = cfg.Permissions
}
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 {
return fmt.Errorf("invalid config: %w", err)
}
+28 -20
View File
@@ -49,17 +49,25 @@ func TestConfigCascade(t *testing.T) {
t.Parallel()
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.Len(t, cfg.Rules, 1)
require.Len(t, cfg.Users, 2)
require.True(t, cfg.Users[0].Modify)
require.True(t, cfg.Users[0].Permissions.Read)
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.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.Len(t, cfg.Users[1].Rules, 0)
}
@@ -67,10 +75,10 @@ func TestConfigCascade(t *testing.T) {
t.Run("YAML", func(t *testing.T) {
content := `
directory: /
modify: true
permissions: CR
rules:
- path: /public/access/
modify: true
permissions: R
users:
- username: admin
@@ -78,7 +86,7 @@ users:
- username: basic
password: basic
directory: /basic
modify: false
permissions: R
rules: []`
cfg := writeAndParseConfig(t, content, ".yml")
@@ -90,11 +98,11 @@ users:
t.Run("JSON", func(t *testing.T) {
content := `{
"directory": "/",
"modify": true,
"permissions": "CR",
"rules": [
{
"path": "/public/access/",
"modify": true
"permissions": "R"
}
],
"users": [
@@ -106,7 +114,7 @@ users:
"username": "basic",
"password": "basic",
"directory": "/basic",
"modify": false,
"permissions": "R",
"rules": []
}
]
@@ -121,11 +129,11 @@ users:
t.Run("`TOML", func(t *testing.T) {
content := `
directory = "/"
modify = true
permissions = "CR"
[[rules]]
path = "/public/access/"
modify = true
permissions = "R"
[[users]]
username = "admin"
@@ -135,7 +143,7 @@ password = "admin"
username = "basic"
password = "basic"
directory = "/basic"
modify = false
permissions = "R"
rules = []
`
@@ -175,12 +183,9 @@ cors:
func TestConfigRules(t *testing.T) {
content := `
directory: /
modify: true
rules:
- regex: '^.+\.js$'
modify: true
- path: /public/access/
modify: true`
- path: /public/access/`
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
@@ -199,7 +204,7 @@ rules:
func TestConfigEnv(t *testing.T) {
require.NoError(t, os.Setenv("WD_PORT", "1234"))
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"))
cfg, err := ParseConfig("", nil)
@@ -208,11 +213,14 @@ func TestConfigEnv(t *testing.T) {
assert.Equal(t, 1234, cfg.Port)
assert.Equal(t, "/test", cfg.Directory)
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
require.NoError(t, os.Setenv("WD_PORT", ""))
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", ""))
}
+12 -2
View File
@@ -2,6 +2,8 @@ package lib
import (
"net/http"
"net/url"
"os"
"strings"
"github.com/rs/cors"
@@ -23,7 +25,7 @@ func NewHandler(c *Config) (http.Handler, error) {
h := &Handler{
user: &handlerUser{
User: User{
Permissions: c.Permissions,
UserPermissions: c.UserPermissions,
},
Handler: webdav.Handler{
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.
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))
+33 -8
View File
@@ -133,7 +133,7 @@ func TestServerAuthentication(t *testing.T) {
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
modify: true
permissions: CRUD
users:
- username: basic
@@ -191,39 +191,58 @@ func TestServerRules(t *testing.T) {
"a/foo.js": []byte("foo js"),
"a/foo.txt": []byte("foo txt"),
"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(`
directory: %s
modify: true
permissions: CRUD
users:
- username: basic
password: basic
rules:
- regex: "^.+.js$"
modify: false
permissions: R
- path: "/b"
modify: false
permissions: R
- path: "/a/foo.txt"
permissions: none
- path: "/c"
permissions: none
`, dir))
client := gowebdav.NewClient(srv.URL, "basic", "basic")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 3)
require.Len(t, files, 4)
err = client.Write("/foo.txt", []byte("new"), 0666)
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)
_, err = client.Read("/a/foo.txt")
require.ErrorContains(t, err, "403")
err = client.Write("/a/foo.js", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
err = client.Write("/b/foo.txt", []byte("new"), 0666)
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) {
@@ -237,7 +256,7 @@ func TestServerPermissions(t *testing.T) {
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
modify: true
permissions: CR
users:
- username: a
@@ -246,7 +265,7 @@ users:
- username: b
password: b
directory: %s/b
modify: false
permissions: R
`, dir, dir, dir))
t.Run("User A", func(t *testing.T) {
@@ -265,6 +284,12 @@ users:
err = client.Copy("/foo.txt", "/copy.txt", false)
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")
require.NoError(t, err)
require.EqualValues(t, []byte("foo a"), data)
+68 -24
View File
@@ -9,16 +9,8 @@ import (
"strings"
)
var readMethods = []string{
http.MethodGet,
http.MethodHead,
http.MethodOptions,
"PROPFIND",
}
type Rule struct {
Allow bool
Modify bool
Permissions Permissions
Path string
Regex *regexp.Regexp
}
@@ -40,36 +32,27 @@ func (r *Rule) Matches(path string) bool {
return strings.HasPrefix(path, r.Path)
}
type Permissions struct {
type UserPermissions struct {
Directory string
Modify bool
Permissions Permissions
Rules []*Rule
}
// Allowed checks if the user has permission to access a directory/file
func (p Permissions) Allowed(r *http.Request) 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
}
}
func (p UserPermissions) Allowed(r *http.Request, destinationExists func(string) bool) bool {
// Go through rules beginning from the last one.
for i := len(p.Rules) - 1; i >= 0; i-- {
rule := p.Rules[i]
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
p.Directory, err = filepath.Abs(p.Directory)
@@ -85,3 +68,64 @@ func (p *Permissions) Validate() error {
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
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
)
type User struct {
Permissions `mapstructure:",squash"`
UserPermissions `mapstructure:",squash"`
Username string
Password string
}
@@ -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)
}