Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions pkg/reconciliation/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright 2018-2026 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.

package reconciliation

import (
"github.com/cs3org/reva/v3/pkg/storage/utils/acl"
"github.com/pkg/errors"
)

// Enforcement decides how the reconciler treats a default ACL entry that
// diverges from what is on the storage.
type Enforcement string

const (
// EnforcementMay means the entry is allowed to be present but is not
// required. The reconciler never adds it and never removes it: a "may"
// entry on disk is left untouched. Used for the global defaults
// (cbackeosro, cboxexternal) that may live anywhere.
EnforcementMay Enforcement = "may"
// EnforcementMust means the entry has to be present everywhere in scope.
// The reconciler adds it when missing and corrects it when its permissions
// differ, and never removes it. Used for the personal-space owner and the
// project reader/writer/admin egroups.
EnforcementMust Enforcement = "must"
)

// DefaultScanner is the default NamespaceScanner used for the full-namespace
// sweep. It shells out to the eos-ns-inspect binary.
const DefaultScanner = "eos-nsinspect-binary"

// Config configures the reconciliation engine. It is decoded from the job's own
// configuration section.
type Config struct {
// DryRun, when set, makes every job compute and report the ACL changes it
// would make without applying any of them.
DryRun bool `mapstructure:"dry_run"`
// Scanner selects the registered NamespaceScanner used by the
// full-namespace sweep (level 3). Defaults to DefaultScanner.
Scanner string `mapstructure:"scanner"`
// PathPrefixes maps filesystem path prefixes to the default ACL entries
// that apply under them. A space is governed by the single rule whose prefix
// is a path prefix of its root. Prefixes may not overlap, so at most one rule
// matches a space. See Config.DefaultACLs.
PathPrefixes []PathPrefixRule `mapstructure:"path_prefix"`
}

// PathPrefixRule associates a path prefix with a set of default ACL entries.
type PathPrefixRule struct {
// Prefix is the filesystem path prefix the rule applies to, e.g.
// "/eos/user" or "/eos/project".
Prefix string `mapstructure:"prefix"`
// DefaultACLs are the default entries that apply under Prefix.
DefaultACLs []DefaultACLRule `mapstructure:"default_acl"`
}

// DefaultACLRule is a single default ACL entry and how strictly it is enforced.
// The qualifier may contain the templates "{owner}" and "{project}", resolved
// per space when the defaults are computed.
type DefaultACLRule struct {
// Type is the ACL entry type: "u" (user), "egroup" (group) or "lw"
// (lightweight). See package acl.
Type string `mapstructure:"type"`
// Qualifier identifies the grantee. May contain "{owner}" or "{project}".
Qualifier string `mapstructure:"qualifier"`
// Permissions is the EOS permission string, e.g. "rx", "rwx" or "rwx+d".
Permissions string `mapstructure:"permissions"`
// Enforcement is "must" or "may".
Enforcement Enforcement `mapstructure:"enforcement"`
}

// ApplyDefaults implements cfg.Setter.
func (c *Config) ApplyDefaults() {
if c.Scanner == "" {
c.Scanner = DefaultScanner
}
}

// Validate checks that the configuration is internally consistent. It is
// separate from ApplyDefaults so a caller can decode, default and validate in
// that order and surface a precise error.
func (c *Config) Validate() error {
for i := range c.PathPrefixes {
if err := c.PathPrefixes[i].validate(); err != nil {
return errors.Wrapf(err, "reconciliation: path_prefix[%d]", i)
}
}
return nil
}

func (r *PathPrefixRule) validate() error {
if r.Prefix == "" {
return errors.New("prefix must not be empty")
}
for j := range r.DefaultACLs {
if err := r.DefaultACLs[j].validate(); err != nil {
return errors.Wrapf(err, "default_acl[%d]", j)
}
}
return nil
}

func (d *DefaultACLRule) validate() error {
switch d.Type {
case acl.TypeUser, acl.TypeGroup, acl.TypeLightweight:
case "":
return errors.New("type must not be empty")
default:
return errors.Errorf("invalid type %q", d.Type)
}
if d.Qualifier == "" {
return errors.New("qualifier must not be empty")
}
if d.Permissions == "" {
return errors.New("permissions must not be empty")
}
switch d.Enforcement {
case EnforcementMay, EnforcementMust:
case "":
return errors.New("enforcement must not be empty")
default:
return errors.Errorf("invalid enforcement %q", d.Enforcement)
}
return nil
}
145 changes: 145 additions & 0 deletions pkg/reconciliation/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright 2018-2026 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.

package reconciliation

import (
"testing"

"github.com/cs3org/reva/v3/pkg/utils/cfg"
)

func TestConfigDecodeAndDefaults(t *testing.T) {
in := map[string]any{
"dry_run": true,
"path_prefix": []map[string]any{
{
"prefix": "/eos/user",
"default_acl": []map[string]any{
{"type": "u", "qualifier": "{owner}", "permissions": "rwx", "enforcement": "must"},
{"type": "egroup", "qualifier": "cbackeosro", "permissions": "rx", "enforcement": "may"},
},
},
},
}

var c Config
if err := cfg.Decode(in, &c); err != nil {
t.Fatalf("decode: %v", err)
}

if !c.DryRun {
t.Errorf("DryRun = false, want true")
}
if c.Scanner != DefaultScanner {
t.Errorf("Scanner = %q, want default %q", c.Scanner, DefaultScanner)
}
if len(c.PathPrefixes) != 1 {
t.Fatalf("PathPrefixes = %d, want 1", len(c.PathPrefixes))
}
rule := c.PathPrefixes[0]
if rule.Prefix != "/eos/user" {
t.Errorf("rule = %+v, unexpected prefix", rule)
}
if len(rule.DefaultACLs) != 2 {
t.Fatalf("DefaultACLs = %d, want 2", len(rule.DefaultACLs))
}
if got := rule.DefaultACLs[0]; got.Qualifier != "{owner}" || got.Enforcement != EnforcementMust {
t.Errorf("DefaultACLs[0] = %+v, unexpected", got)
}
if err := c.Validate(); err != nil {
t.Errorf("Validate: %v", err)
}
}

func TestConfigScannerOverride(t *testing.T) {
var c Config
if err := cfg.Decode(map[string]any{"scanner": "custom"}, &c); err != nil {
t.Fatalf("decode: %v", err)
}
if c.Scanner != "custom" {
t.Errorf("Scanner = %q, want %q", c.Scanner, "custom")
}
}

func TestConfigValidate(t *testing.T) {
valid := DefaultACLRule{Type: "u", Qualifier: "{owner}", Permissions: "rwx", Enforcement: EnforcementMust}

tests := []struct {
name string
cfg Config
wantErr bool
}{
{
name: "ok",
cfg: Config{PathPrefixes: []PathPrefixRule{
{Prefix: "/eos/user", DefaultACLs: []DefaultACLRule{valid}},
}},
},
{
name: "missing prefix",
cfg: Config{PathPrefixes: []PathPrefixRule{{DefaultACLs: []DefaultACLRule{valid}}}},
wantErr: true,
},
{
name: "invalid acl type",
cfg: Config{PathPrefixes: []PathPrefixRule{
{Prefix: "/eos/user", DefaultACLs: []DefaultACLRule{
{Type: "x", Qualifier: "q", Permissions: "rx", Enforcement: EnforcementMay},
}},
}},
wantErr: true,
},
{
name: "missing qualifier",
cfg: Config{PathPrefixes: []PathPrefixRule{
{Prefix: "/eos/user", DefaultACLs: []DefaultACLRule{
{Type: "u", Permissions: "rx", Enforcement: EnforcementMay},
}},
}},
wantErr: true,
},
{
name: "missing permissions",
cfg: Config{PathPrefixes: []PathPrefixRule{
{Prefix: "/eos/user", DefaultACLs: []DefaultACLRule{
{Type: "u", Qualifier: "q", Enforcement: EnforcementMay},
}},
}},
wantErr: true,
},
{
name: "invalid enforcement",
cfg: Config{PathPrefixes: []PathPrefixRule{
{Prefix: "/eos/user", DefaultACLs: []DefaultACLRule{
{Type: "u", Qualifier: "q", Permissions: "rx", Enforcement: "sometimes"},
}},
}},
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cfg.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
108 changes: 108 additions & 0 deletions pkg/reconciliation/default_acls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright 2018-2026 CERN
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// In applying this license, CERN does not waive the privileges and immunities
// granted to it by virtue of its status as an Intergovernmental Organization
// or submit itself to any jurisdiction.

package reconciliation

import (
"strings"

"github.com/cs3org/reva/v3/pkg/storage/utils/acl"
"github.com/pkg/errors"
)

// DefaultEntry is a default ACL entry that must or may exist on every path in a
// space, independent of any particular node. The full-namespace and per-space
// jobs turn each DefaultEntry into an ExpectedACL at every node they visit.
type DefaultEntry struct {
// Entry is the resolved ACL entry (templates already substituted).
Entry *acl.Entry
// Enforcement is "must" or "may".
Enforcement Enforcement
}

// DefaultACLs computes the default ACL entries for space from the configured
// path-prefix rules. It returns the entries of the rule whose prefix is a path
// prefix of the space root. Prefixes may not overlap, so at most one rule
// matches; the qualifier templates {owner} and {project} are resolved against
// the space. Returns nil if no rule matches.
func (c *Config) DefaultACLs(space *Space) ([]DefaultEntry, error) {
if space == nil {
return nil, errors.New("reconciliation: nil space")
}
switch space.Type {
case SpaceTypePersonal, SpaceTypeProject:
default:
return nil, errors.Errorf("reconciliation: space %q has invalid type %q", space.ID, space.Type)
}

for i := range c.PathPrefixes {
rule := &c.PathPrefixes[i]
if !pathHasPrefix(space.Root, rule.Prefix) {
continue
}
out := make([]DefaultEntry, 0, len(rule.DefaultACLs))
for j := range rule.DefaultACLs {
d := &rule.DefaultACLs[j]
qualifier, err := resolveTemplate(d.Qualifier, space)
if err != nil {
return nil, errors.Wrapf(err, "reconciliation: path_prefix[%d].default_acl[%d]", i, j)
}
out = append(out, DefaultEntry{
Entry: &acl.Entry{
Type: d.Type,
Qualifier: qualifier,
Permissions: d.Permissions,
},
Enforcement: d.Enforcement,
})
}
return out, nil
}
return nil, nil
}

// pathHasPrefix reports whether path lies at or under prefix, comparing whole
// path components so that "/eos/user" is a prefix of "/eos/user/j/jdoe" but not
// of "/eos/username". An empty prefix matches any path.
func pathHasPrefix(path, prefix string) bool {
path = strings.TrimRight(path, "/")
prefix = strings.TrimRight(prefix, "/")
if prefix == "" {
return true
}
return path == prefix || strings.HasPrefix(path, prefix+"/")
}

// resolveTemplate substitutes the {owner} and {project} placeholders in a
// qualifier with the space's owner and project. It is an error to use a
// placeholder the space does not populate.
func resolveTemplate(qualifier string, space *Space) (string, error) {
if strings.Contains(qualifier, "{owner}") {
if space.Owner == "" {
return "", errors.Errorf("qualifier %q uses {owner} but space has no owner", qualifier)
}
qualifier = strings.ReplaceAll(qualifier, "{owner}", space.Owner)
}
if strings.Contains(qualifier, "{project}") {
if space.Project == "" {
return "", errors.Errorf("qualifier %q uses {project} but space has no project", qualifier)
}
qualifier = strings.ReplaceAll(qualifier, "{project}", space.Project)
}
return qualifier, nil
}
Loading
Loading