Skip to content
Open
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
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,32 @@ Paths look like the ones you already use:
```bash
cernbox ls /eos/user/g/gdelmont/Documents
cernbox ls /eos/project/c/cernbox/data
cernbox ls home:Documents # your own space, by name
cernbox ls Documents # relative to your home
cernbox ls project/cernbox:data # a space, by its alias
```

Commands that copy between your computer and CERNBox are the exception: there you mark the CERNBox side with `cb:`, because the same path can exist on both.
`cp` and `sync` are the exception: there you mark the CERNBox side with `cb:`, because the same path can exist on both. After the marker, `~` is your home.

```bash
cernbox cp ./report.pdf cb:/eos/user/g/gdelmont/Documents/
cernbox cp cb:/eos/user/g/gdelmont/Documents/report.pdf .
cernbox cp ./report.pdf cb:~/Documents/
cernbox cp cb:~/Documents/report.pdf .
cernbox cp -r cb:/eos/project/c/cernbox/data ./data
```

### Acting as another user

An admin of the CERNBox deployment can do anything another user can, as them — look at their shares, put a file where they will find it. `--as` acts as them for the whole command; `USER@cb:` in front of a path reaches that one path as them, the way `scp` names a user and a host:

```bash
cernbox --as marie share list # marie's shares, as she sees them
cernbox ls marie@cb:~/Documents # same as --as marie ls Documents
cernbox cp ./fix.txt marie@cb:~/ # into marie's home, written as marie
cernbox cp cb:~/report.pdf marie@cb:~/Documents/ # from your space to hers
cernbox ls marie@cb:project/cernbox:data # a project, as marie sees it
```

A command acts as one user, except `cp` and `sync`, whose two sides may differ. A copy between two users goes through your computer, because the server cannot copy across them: the bytes are read as one and written as the other. `cernbox whoami` says whether you are an admin. Every command run as someone else is recorded in the server's audit log under your name.

## Browsing

```bash
Expand Down
11 changes: 7 additions & 4 deletions dev/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends musl-tools \
&& rm -rf /var/lib/apt/lists/*

# The reva branch carrying the Kerberos auth manager and the SPNEGO credential
# strategy. gaia resolves a branch name through the GitHub API, so this follows
# the branch head; point it at a released version once the branch is merged.
ARG REVA_VERSION=kerberos-auth
# A reva integration branch, cernbox-cli-dev, that joins the two pieces this
# environment needs and that are not in master yet: Kerberos authentication
# (cs3org/reva#5827: the auth manager and the SPNEGO credential strategy) and
# the admin HTTP service --as uses (cs3org/reva#5863). It has no pull request of
# its own. gaia resolves a branch name through the GitHub API, so this follows
# its head; point it back at master, or a release, once both are merged.
ARG REVA_VERSION=cernbox-cli-dev

# cgo, rather than CGO_ENABLED=0: the sql share driver runs on sqlite, which
# needs cgo, and it is the only share driver whose "not found" error the
Expand Down
13 changes: 13 additions & 0 deletions dev/revad/cernbox.toml
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,15 @@ driver = "json"
[grpc.services.groupprovider.drivers.json]
groups = "/etc/revad/groups.demo.json"

# The Admin API, for --as. einstein is in sailing-lovers and so is an admin;
# marie and richard are not, which is what the refusal tests need. The socket is
# off because the image is scratch and has no /run to bind it in.
[grpc.services.admin]
address = ":19600"
admin_group = "sailing-lovers"
machine_auth_apikey = "{{ vars.machine_api_key }}"
socket = "off"


### HTTP ENDPOINTS ###

Expand Down Expand Up @@ -505,6 +514,10 @@ insecure = true
[http.services.ocm]
address = ":443"

# The HTTP face of the Admin API: /admin/status and /admin/impersonate.
[http.services.admin]
address = ":443"

[http.services.sciencemesh]
address = ":443"
provider_domain = "revad"
Expand Down
32 changes: 29 additions & 3 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Everything the CLI needs is already exposed over HTTPS by the CERNBox frontend.
| Identity | `GET /graph/v1.0/me` | |
| Recursive download | archiver service, URL and formats from capabilities | One request for a whole tree |
| App tokens | OCS connected-clients API | Backed by [appauth](https://github.com/cs3org/reva/blob/master/pkg/auth/manager/appauth/appauth.go) |
| Admin status, impersonation | `GET /admin/status`, `POST /admin/impersonate` | reva's `admin` HTTP service, the HTTPS face of the gRPC Admin API (§3.6) |

Locks (`LOCK`/`UNLOCK`) are available on ocdav but are lower priority for a CLI.

Expand Down Expand Up @@ -169,6 +170,14 @@ Each cache entry is keyed by `(endpoint, principal-or-subject)`. A user who does
- **No ticket available**: create a scoped app token, `cernbox token create --path /eos/project/x --permission read --expiry 2026-12-31`, and expose it via `$CERNBOX_APP_TOKEN`. Reva already supports path- and share-scoped app tokens — see `getPathScope` in [app-tokens-create.go:199](https://github.com/cs3org/reva/blob/master/cmd/reva/app-tokens-create.go#L199) — so these can be least-privilege rather than full-account credentials, and the CLI should make the scoped form the documented default.
- **Service accounts**: a keytab plus `kinit -kt` before invoking, or an app token. Both work unchanged.

### 3.6 Acting as another user

An admin of the deployment — a member of the Admin API's `admin_group` — can run any command as another user with `--as USER`. reva has no "admin may touch other users' data" logic: the only power is impersonation, which hands out an ordinary user token for the target. So `--as` changes one thing, the credential, and everything else follows from whom the server says the token belongs to: `/me` answers as the target, path-addressed URLs are built under their name, `share list` lists their shares. No command knows about it.

The token comes from `POST /admin/impersonate` with the admin's own credential. The server steps the caller up and impersonates in one request, so the admin token never reaches the client. Both steps are in reva's audit log. The impersonation token is never cached: every command asks again, so each has its own audit entry, and no token for someone else's account is left in `/tmp`. It lives as long as a token from signing in (the Admin API's `impersonation_ttl`, or the token manager's own lifetime, a day by default), so a long transfer made as the user finishes; the short `admin_ttl` applies only to the admin token, which stays on the server.

`whoami` shows whether the user is an admin, from `GET /admin/status`, which only checks and is not audited. Acting as someone is transparent: with `--as`, `whoami` answers as that user and nothing in the output says who is behind it; the record of that is reva's audit log. A completion never impersonates.

## 4. Path and namespace model

Absolute CS3 paths are canonical, matching what the dav files root already exposes and what users already type for `eos`:
Expand All @@ -185,6 +194,7 @@ Space-qualified aliases are accepted and resolve through the locally cached `me/
home:Documents
project/cernbox:data
<space-id>:relative/path # for scripts holding an ID
Documents # a bare relative path is in the home space
```

Resolution strategy: path-addressed operations go to `/remote.php/dav/files/{user}/{path}`, which accepts absolute CS3 paths at CERN directly. Id-addressed operations — anything where the CLI already holds a drive ID from ocgraph, which is all of sharing — go to `/remote.php/dav/spaces/{space-id}/{rel}`. The spaces listing is cached locally with a short TTL and refreshed on a resolution miss.
Expand All @@ -194,22 +204,37 @@ Resolution strategy: path-addressed operations go to `/remote.php/dav/files/{use
On lxplus, `/eos/user/g/gdelmont` is *both* a valid CERNBox remote path and a real local FUSE mount point. `cernbox cp /eos/user/g/gdelmont/a.txt /eos/user/g/gdelmont/b.txt` is genuinely ambiguous, and guessing would be worse than either answer. So:

- **Namespace commands** — `ls`, `stat`, `find`, `du`, `mkdir`, `rm`, `mv`, `touch`, `cat`, `share`, `link`, `trash`, `versions` — take bare remote paths. There is no local side, so there is no ambiguity.
- **Transfer commands** — `cp`, `sync` — require the remote side to carry a `cb:` prefix:
- **Transfer commands** — `cp`, `sync` — take a path as remote only when it carries the `cb:` marker. Anything else is local, a bare alias such as `home:x` included: an alias is not a marker, and a local name with a colon in it must not turn into a remote path. After the marker, `~` is the home space, which spares `cb:home:`:

```bash
cernbox cp ./report.pdf cb:/eos/user/g/gdelmont/Documents/
cernbox cp ./report.pdf cb:~/Documents/
cernbox cp -r cb:/eos/project/c/cernbox/data ./data
```

`~` only works after the marker. Written first, the shell expands it to the local home before the CLI sees it.

`cb:` is accepted everywhere a remote path is, so a script can be explicit throughout if it prefers.

### Whose view a path is in

The marker can name a user: `marie@cb:~/Documents`, `marie@cb:project/cernbox:data`, `marie@cb:/eos/user/m/marie/x`. The path is then marie's view of CERNBox — her home, the spaces she sees — and reaching it means acting as her (§3.6), which only an admin can. It is the shape of scp's `user@host:path`, so it reads as what it is, and it is the only place an identity is written into a path; `pkg/pathspec` carries it on the parsed spec and nothing else interprets it.

A command acts as one user. A `USER@cb:` path makes the whole command act as USER, as `--as` would, and two paths naming different users are refused. `cp` and `sync` are the exception, because their two sides are two places that may belong to two people: each side is acted on as its own user. When the two differ, the server cannot copy — a `COPY` carries one credential — so the bytes are relayed through the client, read as one user and written as the other, file by file, never touching local disk. `copy` and `paste` refuse a user's path, because the clipboard belongs to the signed-in user and a path in someone else's view would quietly make it theirs.

Naming yourself is not an impersonation: `gdelmont@cb:` from gdelmont is just `cb:`, and leaves nothing in the audit log.

### A local mount as a faster way to the same place

Not built, but kept possible. On lxplus a `cb:` path is often also mounted locally. `cb:` says *what* an argument is — a CERNBox resource — and the same command means the same thing on every machine. *How* it is reached is the transfer engine's business: having resolved the spec, it could notice that the path is mounted and use the mount instead of HTTPS. That only holds when acting as yourself, the local Unix user is the CERNBox user, and the mount is the same storage; any `USER@cb:` path goes through the server. Nothing in the path grammar needs to change for it.

## 5. Command surface

```
cernbox login [--method kerberos|device|app-token]
cernbox logout
cernbox status # provider, identity, expiry, endpoint
cernbox whoami [--output json]
cernbox whoami [--output json] # identity, and whether an admin
cernbox --as USER CMD ... # any command, as another user (admins)

cernbox ls [-l] [-r] [--all] PATH
cernbox stat PATH
Expand Down Expand Up @@ -326,6 +351,7 @@ Errors are mapped from CS3 status codes and HTTP status to human sentences, with
- App tokens are created scoped by default; the unscoped form requires an explicit `--all`.
- `--insecure` and `--skip-verify` exist for dev instances, print a warning to stderr on every use, and are refused when the endpoint is a `cern.ch` host.
- No credential is ever passed as a command line argument in a way that would appear in `ps` output or shell history; `--password` prompts rather than accepting a value.
- Every `--as` command is recorded in reva's audit log as an impersonation by the signed-in admin. Its token is never written to the cache (§3.6).
- Phase 2 caveats — no mutual authentication, per-process replay cache — are documented, not silently assumed away (§3.3).

## 11. Testing
Expand Down
139 changes: 139 additions & 0 deletions integration/admin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//go:build integration

package integration_test

import (
"strings"
"testing"
)

// The dev revad makes einstein an admin (he is in sailing-lovers, the
// admin_group) and marie not.

func TestWhoamiSaysWhetherAdmin(t *testing.T) {
e := setup(t)

for _, tc := range []struct {
who account
want bool
}{
{e.self(), true},
{e.other(), false},
} {
var me struct {
Username string `json:"username"`
Admin *bool `json:"admin"`
}
e.runJSONAs(tc.who, &me, "whoami")
if me.Admin == nil || *me.Admin != tc.want {
t.Errorf("whoami as %s: admin = %v, want %v", me.Username, me.Admin, tc.want)
}
}
}

func TestAsShowsWhoActs(t *testing.T) {
e := setup(t)

var me struct {
Username string `json:"username"`
Admin *bool `json:"admin"`
}
e.runJSON(&me, "--as", otherUser, "whoami")
if me.Username != otherUser {
t.Errorf("whoami --as = %+v, want %s", me, otherUser)
}
if me.Admin == nil || *me.Admin {
t.Errorf("admin = %v: whoami --as reports the impersonated user, who is not one", me.Admin)
}
}

// TestAsListsAnotherUsersShares is the first reason for --as: seeing what
// someone else has shared, as they see it.
func TestAsListsAnotherUsersShares(t *testing.T) {
e := setup(t)
dir := e.recipientDir()
e.mustRunAs(e.other(), "share", "create", dir, "--with", "richard", "--role", "viewer")

var shares []struct {
Path string `json:"path"`
}
e.runJSON(&shares, "--as", otherUser, "share", "list")
for _, s := range shares {
if s.Path == dir {
return
}
}
t.Errorf("%s's share of %s is not in the listing: %+v", otherUser, dir, shares)
}

// TestAsWritesIntoAnotherUsersHome is the second: putting a file where someone
// else can find it, which the admin's own identity is not allowed to do.
func TestAsWritesIntoAnotherUsersHome(t *testing.T) {
e := setup(t)
dir := e.recipientDir()
local := e.writeLocal("fix.txt", []byte("from the admin"))

if _, _, code := e.run("cp", local, "cb:"+dir+"/"); code != 4 {
t.Fatalf("put into %s without --as exited %d, want 4: the test proves nothing", dir, code)
}

e.mustRun("--as", otherUser, "cp", local, "cb:"+dir+"/")
if got := e.mustRunAs(e.other(), "cat", dir+"/fix.txt"); got != "from the admin" {
t.Errorf("%s reads %q", otherUser, got)
}
}

func TestAsRefusedForNonAdmin(t *testing.T) {
e := setup(t)

_, stderr, code := e.runAs(e.other(), "--as", username, "ls")
if code != 4 {
t.Errorf("exit %d, want 4 (permission); stderr: %s", code, stderr)
}
if !strings.Contains(stderr, "not an administrator") {
t.Errorf("stderr does not say why: %s", stderr)
}
}

// TestIdentityMarkerReachesAnotherUsersHome reads marie's home through
// "marie@cb:~", which the admin's own identity could not list.
func TestIdentityMarkerReachesAnotherUsersHome(t *testing.T) {
e := setup(t)
dir := e.recipientDir()
e.mustRunAs(e.other(), "cp", e.writeLocal("hers.txt", []byte("marie's")), "cb:"+dir+"/")
rel := strings.TrimPrefix(dir, otherHomeRoot+"/")

if _, _, code := e.run("ls", dir); code != 4 {
t.Fatalf("listing %s as %s exited %d, want 4: the test proves nothing", dir, username, code)
}
out := e.mustRun("ls", otherUser+"@cb:~/"+rel)
if !strings.Contains(out, "hers.txt") {
t.Errorf("ls %s@cb:~/%s = %q, want hers.txt", otherUser, rel, out)
}
}

// TestCpBetweenUsers copies from the admin's own space into marie's: read as
// one user, written as the other, which no server-side copy can do.
func TestCpBetweenUsers(t *testing.T) {
e := setup(t)
e.mustRun("cp", e.writeLocal("report.txt", []byte("for marie")), "cb:"+e.remotePath("report.txt"))
dir := e.recipientDir()

e.mustRun("cp", "--verify", "cb:"+e.remotePath("report.txt"), otherUser+"@cb:"+dir+"/")
if got := e.mustRunAs(e.other(), "cat", dir+"/report.txt"); got != "for marie" {
t.Errorf("%s reads %q", otherUser, got)
}
}

func TestCpBetweenUsersCopiesATree(t *testing.T) {
e := setup(t)
e.mustRun("mkdir", "-p", e.remotePath("tree/sub"))
e.mustRun("cp", e.writeLocal("a.txt", []byte("alpha")), "cb:"+e.remotePath("tree/a.txt"))
e.mustRun("cp", e.writeLocal("b.txt", []byte("beta")), "cb:"+e.remotePath("tree/sub/b.txt"))
dir := e.recipientDir()

e.mustRun("cp", "-r", "cb:"+e.remotePath("tree"), otherUser+"@cb:"+dir+"/")
if got := e.mustRunAs(e.other(), "cat", dir+"/tree/sub/b.txt"); got != "beta" {
t.Errorf("%s reads %q for the nested file", otherUser, got)
}
}
Loading
Loading