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
22 changes: 13 additions & 9 deletions docs/operator-manual/app-any-namespace.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ In order to enable this feature, the Argo CD administrator must reconfigure the
The `--application-namespaces` parameter takes a comma-separated list of namespaces where `Applications` are to be allowed in. Each entry of the list supports:

- shell-style wildcards such as `*`, so for example the entry `app-team-*` would match `app-team-one` and `app-team-two`. To enable all namespaces on the cluster where Argo CD is running on, you can just specify `*`, i.e. `--application-namespaces=*`.
- regex, requires wrapping the string in ```/```, example to allow all namespaces except a particular one: ```/^((?!not-allowed).)*$/```.
- regex, requires wrapping the string in `/`, example to allow all namespaces except a particular one: `/^((?!not-allowed).)*$/`.

The startup parameters for both, the `argocd-server` and the `argocd-application-controller` can also be conveniently set up and kept in sync by specifying the `application.namespaces` settings in the `argocd-cmd-params-cm` ConfigMap _instead_ of changing the manifests for the respective workloads. For example:

```yaml
Expand Down Expand Up @@ -94,7 +94,7 @@ metadata:
namespace: argocd
spec:
sourceNamespaces:
- namespace-one
- namespace-one
```

and
Expand All @@ -107,7 +107,7 @@ metadata:
namespace: argocd
spec:
sourceNamespaces:
- namespace-two
- namespace-two
```

In order for an Application to set `.spec.project` to `project-one`, it would have to be created in either namespace `namespace-one` or `argocd`. Likewise, in order for an Application to set `.spec.project` to `project-two`, it would have to be created in either namespace `namespace-two` or `argocd`.
Expand Down Expand Up @@ -138,7 +138,11 @@ For backwards compatibility, if the namespace of the Application is the control

The RBAC syntax for Application objects has been changed from `<project>/<application>` to `<project>/<namespace>/<application>` to accommodate the need to restrict access based on the source namespace of the Application to be managed.

For backwards compatibility, Applications in the `argocd` namespace can still be referred to as `<project>/<application>` in the RBAC policy rules.
For backwards compatibility, Applications in the `argocd` namespace will still be referred to as `<project>/<application>` in the RBAC policy rules.

!!! note

Due to backward compatibility, it is not possible to define RBAC policies specifically for applications in the Argo CD control plane namespace (typically `argocd`) using the pattern `foo/argocd/*`. Applications in the control plane namespace are always normalized to the 2-segment format `<project>/<application>` in RBAC enforcement. For security reasons, an AppProject should never grant access to the control plane namespace through the `.spec.sourceNamespaces` field, as this would allow users to create applications with elevated privileges.

Wildcards do not make any distinction between project and application namespaces yet. For example, the following RBAC rule would match any application belonging to project `foo`, regardless of the namespace it is created in:

Expand All @@ -151,7 +155,7 @@ If you want to restrict access to be granted only to `Applications` in project `
```
p, somerole, applications, get, foo/bar/*, allow
```

## Managing applications in other namespaces

### Declaratively
Expand All @@ -175,10 +179,10 @@ The project `some-project` will then need to specify `some-namespace` in the lis
kind: AppProject
apiVersion: argoproj.io/v1alpha1
metadata:
name: some-project
namespace: argocd
name: some-project
namespace: argocd
spec:
sourceNamespaces:
sourceNamespaces:
- some-namespace
```

Expand Down
24 changes: 21 additions & 3 deletions server/account/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"regexp"
"sort"
"strings"
"time"

"github.com/google/uuid"
Expand All @@ -19,6 +20,7 @@ import (
"github.com/argoproj/argo-cd/v3/server/rbacpolicy"
"github.com/argoproj/argo-cd/v3/util/password"
"github.com/argoproj/argo-cd/v3/util/rbac"
"github.com/argoproj/argo-cd/v3/util/security"
"github.com/argoproj/argo-cd/v3/util/session"
"github.com/argoproj/argo-cd/v3/util/settings"
)
Expand All @@ -28,11 +30,12 @@ type Server struct {
sessionMgr *session.SessionManager
settingsMgr *settings.SettingsManager
enf *rbac.Enforcer
namespace string
}

// NewServer returns a new instance of the Session service
func NewServer(sessionMgr *session.SessionManager, settingsMgr *settings.SettingsManager, enf *rbac.Enforcer) *Server {
return &Server{sessionMgr, settingsMgr, enf}
func NewServer(sessionMgr *session.SessionManager, settingsMgr *settings.SettingsManager, enf *rbac.Enforcer, namespace string) *Server {
return &Server{sessionMgr, settingsMgr, enf, namespace}
}

// UpdatePassword updates the password of the currently authenticated account or the account specified in the request.
Expand Down Expand Up @@ -126,7 +129,22 @@ func (s *Server) CanI(ctx context.Context, r *account.CanIRequest) (*account.Can
return nil, status.Errorf(codes.InvalidArgument, "%v does not contain %s", rbac.Resources, r.Resource)
}

ok := s.enf.Enforce(ctx.Value("claims"), r.Resource, r.Action, r.Subresource)
subresource := r.Subresource

// For project-scoped resources, normalize the subresource using security.RBACName
// This converts "project/defaultNS/name" to "project/name" for backward compatibility
if rbac.ProjectScoped[r.Resource] && s.namespace != "" && subresource != "" {
parts := strings.Split(subresource, "/")
if len(parts) == 3 {
// 3-part format: project/namespace/name
// Normalize: if namespace == defaultNS, becomes project/name; otherwise stays project/namespace/name
subresource = security.RBACName(s.namespace, parts[0], parts[1], parts[2])
}
// if 2 parts, always assume the default namespace
// else: keep as-is (wildcards, etc.)
}

ok := s.enf.Enforce(ctx.Value("claims"), r.Resource, r.Action, subresource)
if ok {
return &account.CanIResponse{Value: "yes"}, nil
}
Expand Down
101 changes: 100 additions & 1 deletion server/account/account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func newTestAccountServerExt(t *testing.T, ctx context.Context, enforceFn rbac.C
enforcer := rbac.NewEnforcer(kubeclientset, testNamespace, common.ArgoCDRBACConfigMapName, nil)
enforcer.SetClaimsEnforcerFunc(enforceFn)

return NewServer(sessionMgr, settingsMgr, enforcer), session.NewServer(sessionMgr, settingsMgr, nil, nil, nil)
return NewServer(sessionMgr, settingsMgr, enforcer, testNamespace), session.NewServer(sessionMgr, settingsMgr, nil, nil, nil)
}

func getAdminAccount(mgr *settings.SettingsManager) (*settings.Account, error) {
Expand Down Expand Up @@ -332,3 +332,102 @@ func TestCanI_GetLogsDeny(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "no", resp.Value)
}

func TestCanI_NormalizeDefaultNamespace(t *testing.T) {
// Test: subresource "myproject/default/myapp" with default namespace "default"
// Expected: normalized to "myproject/myapp" (matches */* policy)
enforcer := func(_ jwt.Claims, rvals ...any) bool {
// Verify the subresource was normalized to 2 segments
if len(rvals) >= 4 {
if obj, ok := rvals[3].(string); ok {
return obj == "myproject/myapp"
}
}
return false
}

accountServer, _ := newTestAccountServerExt(t, t.Context(), enforcer)
ctx := adminContext(t.Context())

// UI sends 3-segment format with default namespace
resp, err := accountServer.CanI(ctx, &account.CanIRequest{
Resource: "logs",
Action: "get",
Subresource: "myproject/default/myapp", // default is default namespace
})
require.NoError(t, err)
assert.Equal(t, "yes", resp.Value)
}

func TestCanI_PreserveNonDefaultNamespace(t *testing.T) {
// Test: subresource "myproject/other-ns/myapp" with default namespace "default"
// Expected: preserved as "myproject/other-ns/myapp" (needs */*/* policy)
enforcer := func(_ jwt.Claims, rvals ...any) bool {
// Verify the subresource was NOT normalized (3 segments)
if len(rvals) >= 4 {
if obj, ok := rvals[3].(string); ok {
return obj == "myproject/other-ns/myapp"
}
}
return false
}

accountServer, _ := newTestAccountServerExt(t, t.Context(), enforcer)
ctx := adminContext(t.Context())

resp, err := accountServer.CanI(ctx, &account.CanIRequest{
Resource: "logs",
Action: "get",
Subresource: "myproject/other-ns/myapp", // other-ns != default
})
require.NoError(t, err)
assert.Equal(t, "yes", resp.Value)
}

func TestCanI_BackwardCompatibleTwoSegment(t *testing.T) {
// Test: old UI sends "myproject/myapp" (2 segments)
// Expected: stays as "myproject/myapp"
enforcer := func(_ jwt.Claims, rvals ...any) bool {
if len(rvals) >= 4 {
if obj, ok := rvals[3].(string); ok {
return obj == "myproject/myapp"
}
}
return false
}

accountServer, _ := newTestAccountServerExt(t, t.Context(), enforcer)
ctx := adminContext(t.Context())

resp, err := accountServer.CanI(ctx, &account.CanIRequest{
Resource: "logs",
Action: "get",
Subresource: "myproject/myapp",
})
require.NoError(t, err)
assert.Equal(t, "yes", resp.Value)
}

func TestCanI_NonProjectScopedResource(t *testing.T) {
// Test: non-project-scoped resources should not be normalized
enforcer := func(_ jwt.Claims, rvals ...any) bool {
if len(rvals) >= 4 {
if obj, ok := rvals[3].(string); ok {
// Should receive the original format unchanged
return obj == "some/value/here"
}
}
return false
}

accountServer, _ := newTestAccountServerExt(t, t.Context(), enforcer)
ctx := adminContext(t.Context())

resp, err := accountServer.CanI(ctx, &account.CanIRequest{
Resource: "accounts", // not project-scoped
Action: "update",
Subresource: "some/value/here",
})
require.NoError(t, err)
assert.Equal(t, "yes", resp.Value)
}
2 changes: 1 addition & 1 deletion server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,7 @@ func newArgoCDServiceSet(a *ArgoCDServer) *ArgoCDServiceSet {
projectService := project.NewServer(a.Namespace, a.KubeClientset, a.AppClientset, a.enf, projectLock, a.sessionMgr, a.policyEnforcer, a.projInformer, a.settingsMgr, a.db, a.EnableK8sEvent)
appsInAnyNamespaceEnabled := len(a.ApplicationNamespaces) > 0
settingsService := settings.NewServer(a.settingsMgr, a.RepoClientset, a, a.DisableAuth, appsInAnyNamespaceEnabled, a.HydratorEnabled, a.SyncWithReplaceAllowed)
accountService := account.NewServer(a.sessionMgr, a.settingsMgr, a.enf)
accountService := account.NewServer(a.sessionMgr, a.settingsMgr, a.enf, a.Namespace)

notificationService := notification.NewServer(a.apiFactory)
certificateService := certificate.NewServer(a.db, a.enf)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ export const ResourceDetails = (props: ResourceDetailsProps) => {

const settings = await services.authService.settings();
const execEnabled = settings.execEnabled;
const logsAllowed = await services.accounts.canI('logs', 'get', application.spec.project + '/' + application.metadata.name);
const execAllowed = execEnabled && (await services.accounts.canI('exec', 'create', application.spec.project + '/' + application.metadata.name));
const logsAllowed = await services.accounts.canI('logs', 'get', AppUtils.appRBACName(application));
const execAllowed = execEnabled && (await services.accounts.canI('exec', 'create', AppUtils.appRBACName(application)));
const links = await services.applications.getResourceLinks(application.metadata.name, application.metadata.namespace, selectedNode).catch(() => null);
const resourceActionsMenuItems = await AppUtils.getResourceActionsMenuItems(selectedNode, application.metadata, appContext);
return {controlledState, liveState, events, podState, execEnabled, execAllowed, logsAllowed, links, childResources, resourceActionsMenuItems};
Expand Down
20 changes: 18 additions & 2 deletions ui/src/app/applications/components/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ function getActionItems(

const logsAction = isApp(application)
? services.accounts
.canI('logs', 'get', application.spec.project + '/' + application.metadata.name)
.canI('logs', 'get', appRBACName(application))
.then(async allowed => {
if (allowed && (isPod || findChildPod(resource, tree as appModels.ApplicationTree))) {
return [
Expand All @@ -768,7 +768,7 @@ function getActionItems(
? services.authService
.settings()
.then(async settings => {
const execAllowed = settings.execEnabled && (await services.accounts.canI('exec', 'create', application.spec.project + '/' + application.metadata.name));
const execAllowed = settings.execEnabled && (await services.accounts.canI('exec', 'create', appRBACName(application)));
if (isPod && execAllowed) {
return [
{
Expand Down Expand Up @@ -1781,6 +1781,22 @@ export function appQualifiedName(app: appModels.AbstractApplication, nsEnabled:
return (nsEnabled ? app.metadata.namespace + '/' : '') + app.metadata.name;
}

/**
* Constructs the RBAC subresource name for canI() checks.
**/
export function appRBACName(app: appModels.Application): string {
const project = app.spec.project;
const namespace = app.metadata.namespace;
const name = app.metadata.name;

// Always include namespace if available - server will normalize
if (namespace) {
return `${project}/${namespace}/${name}`;
}
// Fallback to 2-segment format if namespace is missing
return `${project}/${name}`;
}

export function appInstanceName(app: appModels.AbstractApplication): string {
return app.metadata.namespace + '_' + app.metadata.name;
}
Expand Down
Loading