diff --git a/.agents/skills/use-ocis-cli/SKILL.md b/.agents/skills/use-ocis-cli/SKILL.md index ec28a2c..0cdd8d9 100644 --- a/.agents/skills/use-ocis-cli/SKILL.md +++ b/.agents/skills/use-ocis-cli/SKILL.md @@ -43,8 +43,9 @@ protocol-level work. - Prefer the global `--json` or `--jsonl` output when consuming results. Do not parse human-readable tables when structured output is available. - Use read-only discovery commands such as `ls`, `stat`, `search`, `tree`, - `space list`, `share overview`, `trash list`, and admin `list` or `info` - commands to resolve names and IDs before changing anything. + `space list`, `share overview`, `federation connection list`, `trash list`, + and admin `list` or `info` commands to resolve names and IDs before changing + anything. - Interpret a remote path in the selected Space. Keep local filesystem paths and remote oCIS paths distinct according to the command help. @@ -55,6 +56,9 @@ protocol-level work. should be reconciled. Run a sync with `--dry-run` first. - Use `share received` or `share overview` to inspect shares. Never accept a share automatically. +- Use `federation invite` and `federation connection` to establish and inspect + OCM identity connections. An invitation establishes a connection; sharing a + file or folder is a separate explicit operation. - Use `trash` for recoverable deletion management and `version` for historical file versions. - Use `admin` only when the user explicitly requests administration. A normal @@ -77,14 +81,16 @@ shares, disables, or deletes data: Never add `--yes` merely to avoid a prompt. Use it only when the user has explicitly authorized that exact operation. Do not silently overwrite files, empty trash, permanently delete resources, disable or delete accounts, change -roles, accept or decline shares, or execute unreviewed batch input. +roles, accept federation invitations, remove federation connections, accept or +decline shares, or execute unreviewed batch input. ## Protect authentication and secrets - Let `ocis auth login PROFILE` handle interactive browser login and server- required MFA. Tell the user when browser interaction is required. - Never ask the user to paste a password, access token, refresh token, client - secret, keyring record, or TUS resume URL into the conversation. + secret, keyring record, federation invitation token, or TUS resume URL into + the conversation. - Never inspect, print, export, or copy operating-system keyring contents. - Never expose credential-bearing environment variables, authorization headers, local secret storage, or complete diagnostic output that may contain secrets. diff --git a/.agents/skills/use-ocis-cli/references/commands.md b/.agents/skills/use-ocis-cli/references/commands.md index 68cc27b..b471e32 100644 --- a/.agents/skills/use-ocis-cli/references/commands.md +++ b/.agents/skills/use-ocis-cli/references/commands.md @@ -18,6 +18,7 @@ same command. - Synchronization - Spaces - Sharing +- Federation - Metadata, trash, and versions - Administration - CLI utilities and global flags @@ -91,6 +92,7 @@ to change their saved default. | `share roles` | List server-advertised sharing roles for a resource. | | `share user add` | Grant a user access to a remote resource. | | `share group add` | Grant a group access to a remote resource. | +| `share federated add/roles` | Grant an accepted OCM user access using a server-advertised federated role. | | `share list, share ls` | List outgoing shares for a resource. | | `share overview` | List outgoing and received shares across Spaces. | | `share received` | List shares received by the current user. | @@ -103,6 +105,17 @@ to change their saved default. Never accept a received share without an explicit user request. +## Federation + +| Command | Purpose | +| --- | --- | +| `federation invite create/list/accept` | Establish an OCM connection between users on two federation-enabled servers. | +| `federation connection list/remove` | Inspect or remove accepted remote-user connections. | + +An invitation token establishes identity trust; it does not share a resource. +Treat invitation tokens as secrets and never accept one without an explicit +user request. + ## Metadata, trash, and versions | Command | Purpose | diff --git a/.claude/skills/use-ocis-cli/SKILL.md b/.claude/skills/use-ocis-cli/SKILL.md index ec28a2c..0cdd8d9 100644 --- a/.claude/skills/use-ocis-cli/SKILL.md +++ b/.claude/skills/use-ocis-cli/SKILL.md @@ -43,8 +43,9 @@ protocol-level work. - Prefer the global `--json` or `--jsonl` output when consuming results. Do not parse human-readable tables when structured output is available. - Use read-only discovery commands such as `ls`, `stat`, `search`, `tree`, - `space list`, `share overview`, `trash list`, and admin `list` or `info` - commands to resolve names and IDs before changing anything. + `space list`, `share overview`, `federation connection list`, `trash list`, + and admin `list` or `info` commands to resolve names and IDs before changing + anything. - Interpret a remote path in the selected Space. Keep local filesystem paths and remote oCIS paths distinct according to the command help. @@ -55,6 +56,9 @@ protocol-level work. should be reconciled. Run a sync with `--dry-run` first. - Use `share received` or `share overview` to inspect shares. Never accept a share automatically. +- Use `federation invite` and `federation connection` to establish and inspect + OCM identity connections. An invitation establishes a connection; sharing a + file or folder is a separate explicit operation. - Use `trash` for recoverable deletion management and `version` for historical file versions. - Use `admin` only when the user explicitly requests administration. A normal @@ -77,14 +81,16 @@ shares, disables, or deletes data: Never add `--yes` merely to avoid a prompt. Use it only when the user has explicitly authorized that exact operation. Do not silently overwrite files, empty trash, permanently delete resources, disable or delete accounts, change -roles, accept or decline shares, or execute unreviewed batch input. +roles, accept federation invitations, remove federation connections, accept or +decline shares, or execute unreviewed batch input. ## Protect authentication and secrets - Let `ocis auth login PROFILE` handle interactive browser login and server- required MFA. Tell the user when browser interaction is required. - Never ask the user to paste a password, access token, refresh token, client - secret, keyring record, or TUS resume URL into the conversation. + secret, keyring record, federation invitation token, or TUS resume URL into + the conversation. - Never inspect, print, export, or copy operating-system keyring contents. - Never expose credential-bearing environment variables, authorization headers, local secret storage, or complete diagnostic output that may contain secrets. diff --git a/.claude/skills/use-ocis-cli/references/commands.md b/.claude/skills/use-ocis-cli/references/commands.md index 68cc27b..b471e32 100644 --- a/.claude/skills/use-ocis-cli/references/commands.md +++ b/.claude/skills/use-ocis-cli/references/commands.md @@ -18,6 +18,7 @@ same command. - Synchronization - Spaces - Sharing +- Federation - Metadata, trash, and versions - Administration - CLI utilities and global flags @@ -91,6 +92,7 @@ to change their saved default. | `share roles` | List server-advertised sharing roles for a resource. | | `share user add` | Grant a user access to a remote resource. | | `share group add` | Grant a group access to a remote resource. | +| `share federated add/roles` | Grant an accepted OCM user access using a server-advertised federated role. | | `share list, share ls` | List outgoing shares for a resource. | | `share overview` | List outgoing and received shares across Spaces. | | `share received` | List shares received by the current user. | @@ -103,6 +105,17 @@ to change their saved default. Never accept a received share without an explicit user request. +## Federation + +| Command | Purpose | +| --- | --- | +| `federation invite create/list/accept` | Establish an OCM connection between users on two federation-enabled servers. | +| `federation connection list/remove` | Inspect or remove accepted remote-user connections. | + +An invitation token establishes identity trust; it does not share a resource. +Treat invitation tokens as secrets and never accept one without an explicit +user request. + ## Metadata, trash, and versions | Command | Purpose | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 473e1b8..980b6f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,6 +15,7 @@ internal/ auth/ OIDC protocol implementation config/ persisted profile model and atomic storage credentials/ OS credential-service adapter + federation/ ScienceMesh OCM invitation and connection client graph/ LibreGraph Spaces, directory, and permission client httpapi/ authenticated retrying HTTP transport logging/ opt-in diagnostic logging abstraction @@ -73,6 +74,9 @@ without starting a subprocess. protected resumable-upload locations in separate size-bounded entries in macOS Keychain, Linux Secret Service, or Windows Credential Manager; no plaintext or legacy-format migration path exists. +- `internal/federation`: create, list, and accept ScienceMesh invitation tokens + and list or remove accepted OCM user connections. It has no profile, + persistence, Cobra, or resource-sharing policy of its own. - `internal/graph`: discover, create, inspect, update, and control the lifecycle and membership of Spaces through LibreGraph; list, inspect, and mutate directory identities allowed by the server; manage direct group membership @@ -126,8 +130,8 @@ without starting a subprocess. scalar custom-property `PROPFIND`/`PROPPATCH` operations. Protocol-specific behavior belongs in dedicated `internal/auth`, -`internal/graph`, `internal/search`, `internal/sharing`, `internal/trash`, -`internal/versions`, and `internal/webdav` adapters. Recursive local/remote +`internal/federation`, `internal/graph`, `internal/search`, `internal/sharing`, +`internal/trash`, `internal/versions`, and `internal/webdav` adapters. Recursive local/remote traversal belongs in `internal/transfer`. Configuration, credentials, protected upload-session storage, named sync jobs, @@ -159,6 +163,12 @@ Fast package tests remain Docker-independent. LibreGraph drive inventory. It ignores the saved default Space unless an explicit `--space` filter is provided and excludes declined invitations by default. +- Federation connection removal requires explicit intent in both the Cobra and + application layers. Invitation acceptance is always explicit, provider input + is restricted to an HTTP(S) host and optional port, and resource invitations + resolve only server-returned users of type `Federated` using the exact oCIS + Graph filter. Federated roles are requested from the server separately from + local-user roles. - Space names and aliases are convenience selectors. Destructive operations on disabled Spaces use stable IDs. - Server-advertised permissions and roles are authoritative; the CLI does not @@ -225,7 +235,7 @@ Fast package tests remain Docker-independent. - Cancellation propagates through Cobra contexts, application use cases, HTTP requests, and transfer workers and maps to exit code 130. - New behavior requires tests at its narrowest package boundary. -- Core application, authentication, Graph, HTTP transport, search, sharing, - trash, transfer, versions, and WebDAV packages maintain at least 75% +- Core application, authentication, federation, Graph, HTTP transport, search, + sharing, trash, transfer, versions, and WebDAV packages maintain at least 75% statement coverage. - Machine-readable output and exit codes are public compatibility contracts. diff --git a/Makefile b/Makefile index fcb618c..a4fc745 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build check clean coverage fmt install integration integration-down \ +.PHONY: build check clean coverage fmt install uninstall remove integration integration-down \ integration-logs integration-test integration-up lint race release-check \ release release-smoke release-snapshot secrets test vuln @@ -26,6 +26,11 @@ install: build mkdir -p $(HOME)/.local/bin cp bin/ocis $(HOME)/.local/bin/ocis +uninstall: + rm -f $(HOME)/.local/bin/ocis + +remove: uninstall + test: go test ./... @@ -38,7 +43,7 @@ check: fmt coverage: go run ./tools/covercheck -min $(COVERAGE_MIN) \ - app auth graph httpapi retry search sharing sync trash transfer versions \ + app auth federation graph httpapi retry search sharing sync trash transfer versions \ webdav fmt: diff --git a/README.md b/README.md index fe8a538..dde2b41 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ Requires Go 1.26.5 or newer: make test make build make install +make uninstall # alias: make remove ``` The slower black-box compatibility suite starts a disposable, pinned full oCIS @@ -1032,9 +1033,9 @@ ocis share remove SHARE_ID ``` Removal prompts for confirmation and accepts `--yes` only for reviewed -automation. `share list` includes user, group, and public-link shares created -by the caller. `share received [REMOTE_PATH]` lists incoming user/group shares -and is not filtered by `--space`. Filter it with `--state accepted`, +automation. `share list` includes user, group, federated, and public-link shares +created by the caller. `share received [REMOTE_PATH]` lists incoming user, +group, and federated shares and is not filtered by `--space`. Filter it with `--state accepted`, `--state pending`, `--state declined`, or `--state all`. Human output names the state; JSON and JSONL include both the numeric OCS `state` and readable `stateName`. @@ -1127,6 +1128,77 @@ never stored in the config or OS credential service and is not accepted as a command-line value. `--dry-run` is available for creation, update, and revocation. Dry-run output reports that a password would be set but never reads or prints the secret. + +### Federated Open Cloud Mesh sharing + +Federated sharing connects users on two different OCM-enabled oCIS servers. +It has two explicit stages: establish a connection once, then share resources +with that accepted remote user. The CLI never accepts an invitation or a +resource share automatically. + +On the invitation issuer's server, create an invitation: + +```sh +ocis --profile work federation invite create \ + --email bob@remote.example \ + --description "Share project documents" +ocis --profile work federation invite list +``` + +Send the returned token to the other user through a trusted channel. On the +recipient's server, accept it while naming the issuer's public host. A full +`http` or `https` URL is also accepted; paths, queries, credentials, and other +URL schemes are rejected: + +```sh +ocis --profile remote federation invite accept INVITATION_TOKEN \ + --provider cloud.example.com +``` + +To avoid placing the invitation token in shell history, omit the positional +token and enter it at the secure prompt, or set +`OCIS_FEDERATION_INVITE_TOKEN` for non-interactive execution: + +```sh +ocis --profile remote federation invite accept \ + --provider cloud.example.com +``` + +After acceptance, both users can discover the connection and share files or +folders using the server-advertised federated roles: + +```sh +ocis federation connection list +ocis --space Engineering share federated roles /reports/report.pdf +ocis --space Engineering share federated add \ + /reports/report.pdf bob@remote.example --role viewer --dry-run +ocis --space Engineering share federated add \ + /reports/report.pdf bob@remote.example --role viewer +``` + +Incoming OCM resource shares appear in the existing intentional workflow: + +```sh +ocis share received --state pending +ocis share accept SHARE_ID --dry-run +ocis share accept SHARE_ID +``` + +Remove a federated connection only after reviewing it. Removal can make +resources shared through that connection unavailable: + +```sh +ocis federation connection remove bob@remote.example --dry-run +ocis federation connection remove bob@remote.example +``` + +Both servers must enable incoming and outgoing OCM support. The CLI reads the +server's federation capabilities and returns a conflict error before mutation +when the required direction is disabled. Invitations establish a connection; +they do not themselves grant access to any file or Space. Federated users +cannot be added as project Space members in current oCIS, so share a file or +folder inside the Space instead. + The server remains authoritative for directory visibility, available roles, sharing restrictions, and resource permissions. A user may be able to read a file without being allowed to share it, update a share, or remove another diff --git a/internal/app/api.go b/internal/app/api.go index cd55bc6..d6b9135 100644 --- a/internal/app/api.go +++ b/internal/app/api.go @@ -161,6 +161,7 @@ const ( ShareLinkInfo ShareOperation = "link-info" ShareLinkUpdate ShareOperation = "link-update" ShareDirectAdd ShareOperation = "direct-add" + ShareFederatedAdd ShareOperation = "federated-add" ShareDirectUpdate ShareOperation = "direct-update" ShareRemove ShareOperation = "remove" ShareOverview ShareOperation = "overview" @@ -193,6 +194,7 @@ type ShareRequest struct { LinksOnly bool Confirmed bool DryRun bool + Federated bool } // TrashOperation identifies a recycle-bin use case. diff --git a/internal/app/federation_api.go b/internal/app/federation_api.go new file mode 100644 index 0000000..ee70073 --- /dev/null +++ b/internal/app/federation_api.go @@ -0,0 +1,42 @@ +package app + +import "context" + +// FederationOperation identifies an OCM connection-management use case. +type FederationOperation string + +const ( + FederationInviteCreate FederationOperation = "invite-create" + FederationInviteList FederationOperation = "invite-list" + FederationInviteAccept FederationOperation = "invite-accept" + FederationConnectionList FederationOperation = "connection-list" + FederationConnectionRemove FederationOperation = "connection-remove" +) + +// FederationRequest describes one OCM invitation or connection operation. +type FederationRequest struct { + Operation FederationOperation + Token string + Provider string + Email string + Description string + Identifier string + UserID bool + Confirmed bool + DryRun bool +} + +// RunFederationWithOptions manages OCM invitations and connections. +func RunFederationWithOptions( + ctx context.Context, + request FederationRequest, + selectedProfile string, + options RunOptions, +) error { + return classifyProtocolError( + "federation "+string(request.Operation), + runFederation( + ctx, request, selectedProfile, options.normalized(), + ), + ) +} diff --git a/internal/app/federation_service.go b/internal/app/federation_service.go new file mode 100644 index 0000000..d3668c6 --- /dev/null +++ b/internal/app/federation_service.go @@ -0,0 +1,347 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/mzner/ocis-cli/internal/apperror" + "github.com/mzner/ocis-cli/internal/federation" + appoutput "github.com/mzner/ocis-cli/internal/output" +) + +func runFederation( + ctx context.Context, + request FederationRequest, + selectedProfile string, + options RunOptions, +) error { + if request.Operation == FederationConnectionRemove && !request.Confirmed { + return usageFederation( + "removing a federation connection requires explicit confirmation", + ) + } + client, err := newClientWithOptions(ctx, selectedProfile, options) + if err != nil { + return err + } + capabilities, err := client.sharingClient().Capabilities(ctx) + if err != nil { + return fmt.Errorf("check federation capabilities: %w", err) + } + switch request.Operation { + case FederationInviteCreate, FederationInviteList: + if !capabilities.Sharing.Federation.Outgoing { + return federationDisabled("outgoing federation is disabled by the server") + } + case FederationInviteAccept: + if !capabilities.Sharing.Federation.Incoming { + return federationDisabled("incoming federation is disabled by the server") + } + case FederationConnectionList, FederationConnectionRemove: + if !capabilities.Sharing.Federation.Incoming && + !capabilities.Sharing.Federation.Outgoing { + return federationDisabled("federation is disabled by the server") + } + default: + return usageFederation(fmt.Sprintf( + "unknown federation command %q", request.Operation, + )) + } + + switch request.Operation { + case FederationInviteCreate: + value, createErr := client.federationClient().CreateInvitation( + ctx, federation.CreateInvitationRequest{ + Recipient: strings.TrimSpace(request.Email), + Description: strings.TrimSpace(request.Description), + }, + ) + if createErr != nil { + return createErr + } + return writeFederationInvitation(value, options) + case FederationInviteList: + values, listErr := client.federationClient().ListInvitations(ctx) + if listErr != nil { + return listErr + } + sort.Slice(values, func(left, right int) bool { + return values[left].Expiration < values[right].Expiration + }) + return writeFederationInvitations(values, options) + case FederationInviteAccept: + provider, providerErr := normalizeProviderDomain(request.Provider) + if providerErr != nil { + return usageFederation(providerErr.Error()) + } + if strings.TrimSpace(request.Token) == "" { + return usageFederation("invitation token must not be empty") + } + if acceptErr := client.federationClient().AcceptInvitation( + ctx, federation.AcceptInvitationRequest{ + Token: request.Token, ProviderDomain: provider, + }, + ); acceptErr != nil { + return acceptErr + } + return output( + options, "federation-invitation", + map[string]string{"status": "accepted", "provider": provider}, + "Accepted federation invitation from %s\n", provider, + ) + case FederationConnectionList: + values, listErr := client.federationClient().ListConnections(ctx) + if listErr != nil { + return listErr + } + values = filterFederationConnections(values, request.Identifier) + sortFederationConnections(values) + return writeFederationConnections(values, options) + case FederationConnectionRemove: + values, listErr := client.federationClient().ListConnections(ctx) + if listErr != nil { + return listErr + } + selected, resolveErr := resolveFederationConnection( + values, request.Identifier, request.Provider, request.UserID, + ) + if resolveErr != nil { + return resolveErr + } + result := map[string]any{ + "operation": "remove", "userId": selected.UserID, + "displayName": selected.DisplayName, + "provider": selected.Provider, "dryRun": request.DryRun, + } + if request.DryRun { + return output( + options, "federation-connection", result, + "Would remove federation connection with %s (%s)\n", + fallback(selected.DisplayName, selected.UserID), selected.Provider, + ) + } + if deleteErr := client.federationClient().DeleteConnection( + ctx, federation.DeleteConnectionRequest{ + Provider: selected.Provider, UserID: selected.UserID, + }, + ); deleteErr != nil { + return deleteErr + } + return output( + options, "federation-connection", result, + "Removed federation connection with %s (%s)\n", + fallback(selected.DisplayName, selected.UserID), selected.Provider, + ) + } + return nil +} + +func writeFederationInvitation( + value federation.Invitation, options RunOptions, +) error { + if options.OutputMode != appoutput.Human { + return writeOutput(options, "federation-invitation", value) + } + if _, err := fmt.Fprintf(options.Out, "Invitation token: %s\n", value.Token); err != nil { + return err + } + if value.Expiration != 0 { + if _, err := fmt.Fprintf( + options.Out, "Expires: %s\n", formatFederationExpiration(value.Expiration), + ); err != nil { + return err + } + } + if value.InviteLink != "" { + _, err := fmt.Fprintf(options.Out, "Invite link: %s\n", value.InviteLink) + return err + } + return nil +} + +func writeFederationInvitations( + values []federation.Invitation, options RunOptions, +) error { + if options.OutputMode != appoutput.Human { + return writeOutput(options, "federation-invitation", values) + } + writer := tabwriter.NewWriter(options.Out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(writer, "TOKEN\tEXPIRES\tDESCRIPTION\tINVITE LINK"); err != nil { + return err + } + for _, value := range values { + if _, err := fmt.Fprintf( + writer, "%s\t%s\t%s\t%s\n", value.Token, + formatFederationExpiration(value.Expiration), value.Description, + value.InviteLink, + ); err != nil { + return err + } + } + return writer.Flush() +} + +func writeFederationConnections( + values []federation.Connection, options RunOptions, +) error { + if options.OutputMode != appoutput.Human { + return writeOutput(options, "federation-connection", values) + } + writer := tabwriter.NewWriter(options.Out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln( + writer, "DISPLAY NAME\tMAIL\tPROVIDER\tUSER ID", + ); err != nil { + return err + } + for _, value := range values { + if _, err := fmt.Fprintf( + writer, "%s\t%s\t%s\t%s\n", value.DisplayName, value.Mail, + value.Provider, value.UserID, + ); err != nil { + return err + } + } + return writer.Flush() +} + +func formatFederationExpiration(value int64) string { + if value == 0 { + return "-" + } + return time.Unix(value, 0).UTC().Format(time.RFC3339) +} + +func normalizeProviderDomain(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", errors.New("provider must not be empty") + } + parseValue := value + if !strings.Contains(value, "://") { + parseValue = "//" + value + } + parsed, err := url.Parse(parseValue) + if err != nil || parsed.Host == "" || parsed.Hostname() == "" { + return "", fmt.Errorf("invalid federation provider %q", value) + } + if parsed.Scheme != "" && parsed.Scheme != "https" && parsed.Scheme != "http" { + return "", fmt.Errorf( + "invalid federation provider scheme %q; expected http or https", + parsed.Scheme, + ) + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || + (parsed.Path != "" && parsed.Path != "/") { + return "", fmt.Errorf( + "federation provider %q must contain only a server host and optional port", + value, + ) + } + return parsed.Host, nil +} + +func filterFederationConnections( + values []federation.Connection, search string, +) []federation.Connection { + search = strings.ToLower(strings.TrimSpace(search)) + if search == "" { + return values + } + result := make([]federation.Connection, 0, len(values)) + for _, value := range values { + if strings.Contains(strings.ToLower(value.DisplayName), search) || + strings.Contains(strings.ToLower(value.Mail), search) || + strings.Contains(strings.ToLower(value.UserID), search) || + strings.Contains(strings.ToLower(value.Provider), search) { + result = append(result, value) + } + } + return result +} + +func resolveFederationConnection( + values []federation.Connection, + identifier string, + provider string, + identifierIsUserID bool, +) (federation.Connection, error) { + identifier = strings.TrimSpace(identifier) + provider = strings.TrimSpace(provider) + if identifier == "" { + return federation.Connection{}, usageFederation( + "connection identifier must not be empty", + ) + } + matches := make([]federation.Connection, 0) + for _, value := range values { + if provider != "" && !federationProviderMatches(value.Provider, provider) { + continue + } + matched := strings.EqualFold(value.UserID, identifier) + if !identifierIsUserID { + matched = matched || strings.EqualFold(value.DisplayName, identifier) || + strings.EqualFold(value.Mail, identifier) + } + if matched { + matches = append(matches, value) + } + } + if len(matches) == 1 { + return matches[0], nil + } + if len(matches) == 0 { + return federation.Connection{}, apperror.Wrap( + apperror.KindNotFound, "federation connection", + fmt.Errorf("federation connection %q was not found", identifier), + ) + } + providers := make([]string, 0, len(matches)) + for _, value := range matches { + providers = append(providers, value.Provider) + } + sort.Strings(providers) + return federation.Connection{}, usageFederation(fmt.Sprintf( + "federation connection %q is ambiguous across providers %s; use --provider", + identifier, strings.Join(providers, ", "), + )) +} + +func federationProviderMatches(actual, requested string) bool { + actual = strings.TrimRight(strings.TrimSpace(actual), "/") + requested = strings.TrimRight(strings.TrimSpace(requested), "/") + if strings.EqualFold(actual, requested) { + return true + } + actualHost, actualErr := normalizeProviderDomain(actual) + requestedHost, requestedErr := normalizeProviderDomain(requested) + return actualErr == nil && requestedErr == nil && + strings.EqualFold(actualHost, requestedHost) +} + +func sortFederationConnections(values []federation.Connection) { + sort.Slice(values, func(left, right int) bool { + leftKey := strings.ToLower(values[left].DisplayName + "\x00" + + values[left].Provider + "\x00" + values[left].UserID) + rightKey := strings.ToLower(values[right].DisplayName + "\x00" + + values[right].Provider + "\x00" + values[right].UserID) + return leftKey < rightKey + }) +} + +func federationDisabled(message string) error { + return apperror.Wrap( + apperror.KindConflict, "federation", errors.New(message), + ) +} + +func usageFederation(message string) error { + return apperror.Wrap( + apperror.KindUsage, "federation", errors.New(message), + ) +} diff --git a/internal/app/federation_service_test.go b/internal/app/federation_service_test.go new file mode 100644 index 0000000..193ca09 --- /dev/null +++ b/internal/app/federation_service_test.go @@ -0,0 +1,194 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/mzner/ocis-cli/internal/apperror" + appoutput "github.com/mzner/ocis-cli/internal/output" +) + +func TestFederationInvitationAndConnectionUseCases(t *testing.T) { + var accepted, deleted bool + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + switch request.Method + " " + request.URL.Path { + case "GET /ocs/v2.php/cloud/capabilities": + writeAppOCS(writer, `{"capabilities":{"files_sharing":{ + "federation":{"outgoing":true,"incoming":true} + }}}`) + case "POST /sciencemesh/generate-invite": + _, _ = io.WriteString(writer, `{ + "token":"invite-token","description":"Work", + "expiration":1786291200 + }`) + case "GET /sciencemesh/list-invite": + _, _ = io.WriteString(writer, `[{"token":"invite-token","expiration":1786291200}]`) + case "POST /sciencemesh/accept-invite": + var body map[string]string + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + accepted = body["token"] == "invite-token" && + body["providerDomain"] == "remote.example.test" + case "GET /sciencemesh/find-accepted-users": + _, _ = io.WriteString(writer, `[{ + "display_name":"Bob","idp":"https://remote.example.test", + "user_id":"federated-id","mail":"bob@example.test" + }]`) + case "DELETE /sciencemesh/delete-accepted-user": + deleted = true + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + })) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + + var invitation bytes.Buffer + if err := RunFederationWithOptions( + context.Background(), FederationRequest{ + Operation: FederationInviteCreate, Email: "bob@example.test", + Description: "Work", + }, "", RunOptions{Out: &invitation}, + ); err != nil || !strings.Contains(invitation.String(), "invite-token") { + t.Fatalf("invitation=%q error=%v", invitation.String(), err) + } + if err := RunFederationWithOptions( + context.Background(), FederationRequest{ + Operation: FederationInviteAccept, Token: "invite-token", + Provider: "https://remote.example.test/", + }, "", RunOptions{Out: io.Discard}, + ); err != nil || !accepted { + t.Fatalf("accepted=%t error=%v", accepted, err) + } + var connections bytes.Buffer + if err := RunFederationWithOptions( + context.Background(), FederationRequest{ + Operation: FederationConnectionList, Identifier: "bob", + }, "", RunOptions{Out: &connections, OutputMode: appoutput.JSON}, + ); err != nil || !strings.Contains(connections.String(), `"userId": "federated-id"`) { + t.Fatalf("connections=%q error=%v", connections.String(), err) + } + if err := RunFederationWithOptions( + context.Background(), FederationRequest{ + Operation: FederationConnectionRemove, Identifier: "bob@example.test", + Confirmed: true, DryRun: true, + }, "", RunOptions{Out: io.Discard}, + ); err != nil || deleted { + t.Fatalf("dry-run deleted=%t error=%v", deleted, err) + } + if err := RunFederationWithOptions( + context.Background(), FederationRequest{ + Operation: FederationConnectionRemove, Identifier: "federated-id", + Provider: "remote.example.test", UserID: true, Confirmed: true, + }, "", RunOptions{Out: io.Discard}, + ); err != nil || !deleted { + t.Fatalf("deleted=%t error=%v", deleted, err) + } +} + +func TestFederationConnectionRemoveFailsClosed(t *testing.T) { + t.Setenv("OCIS_CONFIG", filepath.Join(t.TempDir(), "missing", "config.json")) + err := RunFederationWithOptions( + context.Background(), FederationRequest{ + Operation: FederationConnectionRemove, Identifier: "federated-id", + }, "", RunOptions{Out: io.Discard}, + ) + if !apperror.IsKind(err, apperror.KindUsage) || + !strings.Contains(err.Error(), "explicit confirmation") { + t.Fatalf("error: %v", err) + } +} + +func TestNormalizeProviderDomain(t *testing.T) { + for value, expected := range map[string]string{ + "cloud.example.test": "cloud.example.test", + "cloud.example.test:9200": "cloud.example.test:9200", + "https://cloud.example.test/": "cloud.example.test", + } { + actual, err := normalizeProviderDomain(value) + if err != nil || actual != expected { + t.Errorf("%q: got %q, %v", value, actual, err) + } + } + for _, value := range []string{ + "file:///tmp/token", "https://user@example.test", "https://example.test/path", + } { + if _, err := normalizeProviderDomain(value); err == nil { + t.Errorf("accepted invalid provider %q", value) + } + } +} + +func TestFederatedShareUseCase(t *testing.T) { + var invited bool + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + itemBase := "/graph/v1beta1/drives/storage$space/items/storage$space!file" + switch { + case request.URL.Path == "/ocs/v2.php/cloud/capabilities": + writeAppOCS(writer, `{"capabilities":{"files_sharing":{ + "federation":{"outgoing":true,"incoming":true} + }}}`) + case request.Method == "PROPFIND": + writer.WriteHeader(http.StatusMultiStatus) + _, _ = io.WriteString(writer, appVersionFile) + case request.Method == http.MethodGet && + request.URL.Path == itemBase+"/permissions": + if !strings.Contains( + request.URL.Query().Get("$filter"), `@Subject.UserType=="Federated"`, + ) { + t.Fatalf("missing federated role filter: %s", request.URL.RawQuery) + } + _, _ = io.WriteString(writer, `{ + "@libre.graph.permissions.roles.allowedValues":[{ + "id":"viewer-id","displayName":"Can view" + }],"value":[] + }`) + case request.Method == http.MethodGet && + request.URL.Path == "/graph/v1.0/users": + if request.URL.Query().Get("$filter") != "userType eq 'Federated'" { + t.Fatalf("filter: %s", request.URL.RawQuery) + } + _, _ = io.WriteString(writer, `{"value":[{ + "id":"federated-id","displayName":"Bob","userType":"Federated", + "mail":"bob@example.test" + }]}`) + case request.Method == http.MethodPost && + request.URL.Path == itemBase+"/invite": + var body graphInviteBody + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + invited = len(body.Recipients) == 1 && + body.Recipients[0].ObjectID == "federated-id" && + len(body.Roles) == 1 && body.Roles[0] == "viewer-id" + _, _ = io.WriteString(writer, `{"value":[{"id":"ocm-share-id"}]}`) + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + })) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + var output bytes.Buffer + err := RunShareWithOptions( + context.Background(), ShareRequest{ + Operation: ShareFederatedAdd, Path: "/report.txt", + Recipient: "bob@example.test", RecipientType: "federated", + Role: "viewer", Federated: true, + }, "", RunOptions{Out: &output}, + ) + if err != nil || !invited || !strings.Contains(output.String(), "ocm-share-id") { + t.Fatalf("invited=%t output=%q error=%v", invited, output.String(), err) + } +} diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 15de8db..7879462 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -13,6 +13,7 @@ import ( "github.com/mzner/ocis-cli/internal/auth" appconfig "github.com/mzner/ocis-cli/internal/config" "github.com/mzner/ocis-cli/internal/credentials" + "github.com/mzner/ocis-cli/internal/federation" "github.com/mzner/ocis-cli/internal/graph" "github.com/mzner/ocis-cli/internal/httpapi" "github.com/mzner/ocis-cli/internal/logging" @@ -42,6 +43,7 @@ type client struct { sharing *sharing.Client recycle *trash.Client versions *versions.Client + federation *federation.Client space *graph.Drive retries int logger logging.Logger @@ -71,6 +73,13 @@ func (client *client) sharingClient() *sharing.Client { return client.sharing } +func (client *client) federationClient() *federation.Client { + if client.federation == nil { + client.federation = federation.NewClient(client.apiConfig(), client.http) + } + return client.federation +} + func (client *client) searchClient() *search.Client { if client.search == nil { client.search = search.NewClient(client.apiConfig(), client.http) diff --git a/internal/app/share_service.go b/internal/app/share_service.go index f3419b0..1383331 100644 --- a/internal/app/share_service.go +++ b/internal/app/share_service.go @@ -71,7 +71,7 @@ func runShare( return err } switch request.Operation { - case ShareCreate, ShareList, ShareDirectAdd, ShareRoles: + case ShareCreate, ShareList, ShareDirectAdd, ShareFederatedAdd, ShareRoles: if err := client.selectSpace(options.Space); err != nil { return err } @@ -106,6 +106,8 @@ func runShare( return updatePublicLink(ctx, client, request, options) case ShareDirectAdd: return addDirectShare(ctx, client, request, options) + case ShareFederatedAdd: + return addFederatedShare(ctx, client, request, options) case ShareDirectUpdate: return updateDirectShare(ctx, client, request, options) case ShareRemove: @@ -117,7 +119,9 @@ func runShare( case ShareAccept, ShareDecline: return respondToReceivedShare(ctx, client, request, options) case ShareRoles: - return listShareRoles(ctx, client, request.Path, options) + return listShareRoles( + ctx, client, request.Path, request.Federated, options, + ) case ShareRevoke: if err := client.sharingClient().RevokeLink(ctx, request.ID); err != nil { return err @@ -178,7 +182,7 @@ func addDirectShare( ) } _, role, err := resolveDirectRole( - ctx, client, metadata.ResourceID, request.Role, + ctx, client, metadata.ResourceID, request.Role, false, ) if err != nil { return err @@ -231,6 +235,82 @@ func addDirectShare( ) } +func addFederatedShare( + ctx context.Context, client *client, request ShareRequest, options RunOptions, +) error { + request.Recipient = strings.TrimSpace(request.Recipient) + if request.Recipient == "" { + return usageShare("federated recipient must not be empty") + } + capabilities, err := client.sharingClient().Capabilities(ctx) + if err != nil { + return fmt.Errorf("check federation capabilities: %w", err) + } + if !capabilities.Sharing.Federation.Outgoing { + return apperror.Wrap( + apperror.KindConflict, "share federated add", + errors.New("outgoing federation is disabled by the server"), + ) + } + remote := cleanRemote(request.Path) + metadata, err := client.stat(remote) + if err != nil { + return err + } + if metadata.ResourceID == "" { + return fmt.Errorf( + "server did not return a stable resource ID for %s", remote, + ) + } + _, role, err := resolveDirectRole( + ctx, client, metadata.ResourceID, request.Role, true, + ) + if err != nil { + return err + } + recipient, err := resolveFederatedRecipient( + ctx, client, request.Recipient, request.RecipientIsID, + ) + if err != nil { + return err + } + if request.DryRun { + return output( + options, "share", + map[string]any{ + "operation": "add", "path": remote, + "resourceId": metadata.ResourceID, + "recipient": request.Recipient, "recipientId": recipient.ID, + "recipientType": "federated", "role": role.DisplayName, + "roleId": role.ID, "dryRun": true, + }, + "Would share %s with federated user %s as %s\n", + remote, fallback(recipient.DisplayName, recipient.ID), role.DisplayName, + ) + } + permission, err := client.graphClient().InviteItem( + ctx, metadata.ResourceID, + graph.InviteRequest{ + Recipients: []graph.Recipient{{ObjectID: recipient.ID, Type: "user"}}, + Roles: []string{role.ID}, + }, + ) + if err != nil { + return err + } + result := directShareOutput{ + ID: permission.ID, Path: remote, RecipientType: "federated", + RecipientID: recipient.ID, + RecipientName: fallback(recipient.DisplayName, recipient.ID), + RoleID: role.ID, Role: role.DisplayName, + } + return output( + options, "share", result, + "Shared %s with federated user %s as %s\nShare ID: %s\n", + remote, result.RecipientName, result.Role, result.ID, + ) +} + func updateDirectShare( ctx context.Context, client *client, request ShareRequest, options RunOptions, ) error { @@ -240,6 +320,7 @@ func updateDirectShare( } _, role, err := resolveDirectRole( ctx, client, selected.ResourceID, request.Role, + selected.Type == "federated", ) if err != nil { return err @@ -471,8 +552,24 @@ func respondToReceivedShare( } func listShareRoles( - ctx context.Context, client *client, remote string, options RunOptions, + ctx context.Context, + client *client, + remote string, + federated bool, + options RunOptions, ) error { + if federated { + capabilities, err := client.sharingClient().Capabilities(ctx) + if err != nil { + return fmt.Errorf("check federation capabilities: %w", err) + } + if !capabilities.Sharing.Federation.Outgoing { + return apperror.Wrap( + apperror.KindConflict, "share federated roles", + errors.New("outgoing federation is disabled by the server"), + ) + } + } remote = cleanRemote(remote) metadata, err := client.stat(remote) if err != nil { @@ -483,9 +580,16 @@ func listShareRoles( "server did not return a stable resource ID for %s", remote, ) } - permissions, err := client.graphClient().ListItemPermissions( - ctx, metadata.ResourceID, - ) + var permissions graph.Permissions + if federated { + permissions, err = client.graphClient().ListFederatedItemPermissions( + ctx, metadata.ResourceID, + ) + } else { + permissions, err = client.graphClient().ListItemPermissions( + ctx, metadata.ResourceID, + ) + } if err != nil { return err } @@ -520,14 +624,16 @@ func resolveOutgoingShare( if value.ID != shareID { continue } - if directOnly && value.Type != "user" && value.Type != "group" { + if directOnly && value.Type != "user" && value.Type != "group" && + value.Type != "federated" { return sharing.Share{}, usageShare(fmt.Sprintf( "%s is a %s share; use ocis share link update for public links", shareID, value.Type, )) } if !directOnly && value.Type != "user" && - value.Type != "group" && value.Type != "public_link" { + value.Type != "group" && value.Type != "federated" && + value.Type != "public_link" { return sharing.Share{}, usageShare(fmt.Sprintf( "share type %q cannot be removed with this command", value.Type, @@ -548,13 +654,22 @@ func resolveOutgoingShare( func resolveDirectRole( ctx context.Context, client *client, resourceID string, requested string, + federated bool, ) (graph.Permissions, graph.RoleDefinition, error) { requested = strings.TrimSpace(requested) if requested == "" { return graph.Permissions{}, graph.RoleDefinition{}, usageShare("role must not be empty") } - permissions, err := client.graphClient().ListItemPermissions(ctx, resourceID) + var permissions graph.Permissions + var err error + if federated { + permissions, err = client.graphClient().ListFederatedItemPermissions( + ctx, resourceID, + ) + } else { + permissions, err = client.graphClient().ListItemPermissions(ctx, resourceID) + } if err != nil { return graph.Permissions{}, graph.RoleDefinition{}, err } diff --git a/internal/app/space_recipient.go b/internal/app/space_recipient.go index 87bb7ef..5e25208 100644 --- a/internal/app/space_recipient.go +++ b/internal/app/space_recipient.go @@ -65,6 +65,30 @@ func resolveRecipient( return selectRecipient(candidates, identifier, recipientType, usage) } +func resolveFederatedRecipient( + ctx context.Context, + client *client, + identifier string, + isID bool, +) (spaceRecipient, error) { + if isID { + return spaceRecipient{ID: identifier, DisplayName: identifier}, nil + } + users, err := client.graphClient().SearchFederatedUsers(ctx, identifier) + if err != nil { + return spaceRecipient{}, err + } + candidates := make([]spaceRecipient, 0, len(users)) + for _, user := range users { + if strings.EqualFold(user.UserType, "Federated") { + candidates = append(candidates, recipientFromUser(user)) + } + } + return selectRecipient( + candidates, identifier, "federated user", usageShare, + ) +} + func recipientFromUser(user graph.DirectoryUser) spaceRecipient { return spaceRecipient{ ID: user.ID, DisplayName: user.DisplayName, diff --git a/internal/command/federation.go b/internal/command/federation.go new file mode 100644 index 0000000..f936bae --- /dev/null +++ b/internal/command/federation.go @@ -0,0 +1,166 @@ +package command + +import ( + "github.com/mzner/ocis-cli/internal/app" + "github.com/spf13/cobra" +) + +func newFederationCommand(options *globalOptions) *cobra.Command { + command := &cobra.Command{ + Use: "federation", Aliases: []string{"federated", "ocm"}, + Short: "Manage Open Cloud Mesh invitations and connections", + } + command.AddCommand( + newFederationInviteCommand(options), + newFederationConnectionCommand(options), + ) + return command +} + +func newFederationInviteCommand(options *globalOptions) *cobra.Command { + command := &cobra.Command{ + Use: "invite", Aliases: []string{"invitation"}, + Short: "Manage federation invitations", + } + command.AddCommand( + newFederationInviteCreateCommand(options), + newFederationInviteListCommand(options), + newFederationInviteAcceptCommand(options), + ) + return command +} + +func newFederationInviteCreateCommand(options *globalOptions) *cobra.Command { + var email, description string + command := &cobra.Command{ + Use: "create", Short: "Create a federation invitation", Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + return runFederation(command, options, app.FederationRequest{ + Operation: app.FederationInviteCreate, + Email: email, Description: description, + }) + }, + } + command.Flags().StringVar(&email, "email", "", "optional recipient email") + command.Flags().StringVar( + &description, "description", "", "optional invitation description", + ) + return command +} + +func newFederationInviteListCommand(options *globalOptions) *cobra.Command { + return &cobra.Command{ + Use: "list", Aliases: []string{"ls"}, + Short: "List active federation invitations", Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + return runFederation(command, options, app.FederationRequest{ + Operation: app.FederationInviteList, + }) + }, + } +} + +func newFederationInviteAcceptCommand(options *globalOptions) *cobra.Command { + var provider string + command := &cobra.Command{ + Use: "accept [TOKEN]", Short: "Accept a federation invitation", + Args: maximumArgs(1), + RunE: func(command *cobra.Command, args []string) error { + token := "" + var err error + if len(args) == 1 { + token = args[0] + } else { + token, err = readSecret( + command, "OCIS_FEDERATION_INVITE_TOKEN", "Invitation token: ", + ) + if err != nil { + return err + } + } + return runFederation(command, options, app.FederationRequest{ + Operation: app.FederationInviteAccept, + Token: token, Provider: provider, + }) + }, + } + command.Flags().StringVar( + &provider, "provider", "", + "invitation issuer host, URL, or host:port (required)", + ) + _ = command.MarkFlagRequired("provider") + return command +} + +func newFederationConnectionCommand(options *globalOptions) *cobra.Command { + command := &cobra.Command{ + Use: "connection", Aliases: []string{"connections"}, + Short: "Manage accepted federation connections", + } + command.AddCommand( + newFederationConnectionListCommand(options), + newFederationConnectionRemoveCommand(options), + ) + return command +} + +func newFederationConnectionListCommand(options *globalOptions) *cobra.Command { + return &cobra.Command{ + Use: "list [SEARCH]", Aliases: []string{"ls"}, + Short: "List accepted federation connections", Args: maximumArgs(1), + RunE: func(command *cobra.Command, args []string) error { + search := "" + if len(args) == 1 { + search = args[0] + } + return runFederation(command, options, app.FederationRequest{ + Operation: app.FederationConnectionList, Identifier: search, + }) + }, + } +} + +func newFederationConnectionRemoveCommand(options *globalOptions) *cobra.Command { + var provider string + var userID, dryRun, yes bool + command := &cobra.Command{ + Use: "remove USER", Aliases: []string{"rm"}, + Short: "Remove an accepted federation connection", Args: exactArgs(1), + RunE: func(command *cobra.Command, args []string) error { + if !yes && !dryRun { + confirmed, err := confirmAction( + command, "Remove federation connection "+args[0]+"?", + ) + if err != nil || !confirmed { + return err + } + } + return runFederation(command, options, app.FederationRequest{ + Operation: app.FederationConnectionRemove, + Identifier: args[0], Provider: provider, UserID: userID, + Confirmed: true, DryRun: dryRun, + }) + }, + } + command.Flags().StringVar( + &provider, "provider", "", "restrict the match to one provider", + ) + command.Flags().BoolVar( + &userID, "user-id", false, "treat USER as an opaque federated user ID", + ) + command.Flags().BoolVar( + &dryRun, "dry-run", false, "resolve the connection without removing it", + ) + command.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt") + return command +} + +func runFederation( + command *cobra.Command, + options *globalOptions, + request app.FederationRequest, +) error { + return app.RunFederationWithOptions( + command.Context(), request, options.profile, options.runOptions(command), + ) +} diff --git a/internal/command/root.go b/internal/command/root.go index 2eef7d1..7cd0ffb 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -126,6 +126,7 @@ func NewRootCommand() *cobra.Command { newTrashCommand(options), newVersionCommand(options), newShareCommand(options), + newFederationCommand(options), newSearchCommand(options), newSyncCommand(options), newTagCommand(options), diff --git a/internal/command/root_test.go b/internal/command/root_test.go index 07f0356..a01f5df 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -69,6 +69,7 @@ func TestGeneratedHelpIncludesGlobalFlags(t *testing.T) { "cp, copy", "rm, remove", "space", "trash", "version, versions", "share", "search, find", "tag", "favorite", "property", "admin", "sync", "config", "cat", "tree", "du", "batch", "touch", + "federation, federated, ocm", } { if !strings.Contains(help, expected) { t.Fatalf("help does not contain %q:\n%s", expected, help) @@ -76,6 +77,42 @@ func TestGeneratedHelpIncludesGlobalFlags(t *testing.T) { } } +func TestFederationCommandsAndAliasesAreDiscoverable(t *testing.T) { + for _, test := range []struct { + command []string + expected []string + }{ + {[]string{"federation"}, []string{"invite, invitation", "connection, connections"}}, + {[]string{"federation", "invite"}, []string{"create", "list, ls", "accept"}}, + {[]string{"federation", "connection"}, []string{"list, ls", "remove, rm"}}, + {[]string{"share", "federated"}, []string{"add", "roles"}}, + } { + root := NewRootCommand() + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs(append(test.command, "--help")) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, expected := range test.expected { + if !strings.Contains(output.String(), expected) { + t.Fatalf("%v help missing %q:\n%s", test.command, expected, output.String()) + } + } + } + for _, args := range [][]string{ + {"ocm", "invitation", "ls"}, + {"federated", "connections", "ls"}, + {"share", "ocm", "roles", "/report.txt"}, + } { + root := NewRootCommand() + if _, _, err := root.Find(args); err != nil { + t.Fatalf("%v: %v", args, err) + } + } +} + func TestAdminCommandsAndAliasesAreDiscoverable(t *testing.T) { for _, test := range []struct { command []string diff --git a/internal/command/share.go b/internal/command/share.go index 905037f..6478c9b 100644 --- a/internal/command/share.go +++ b/internal/command/share.go @@ -10,7 +10,7 @@ import ( func newShareCommand(options *globalOptions) *cobra.Command { command := &cobra.Command{ - Use: "share", Short: "Manage direct shares and public links", + Use: "share", Short: "Manage direct, federated, and public-link shares", } command.AddCommand( newShareCreateCommand(options), @@ -18,6 +18,7 @@ func newShareCommand(options *globalOptions) *cobra.Command { newShareRevokeCommand(options), newShareRecipientCommand(options, "user"), newShareRecipientCommand(options, "group"), + newShareFederatedCommand(options), newShareRolesCommand(options), newShareUpdateCommand(options), newShareRemoveCommand(options), @@ -30,6 +31,62 @@ func newShareCommand(options *globalOptions) *cobra.Command { return command } +func newShareFederatedCommand(options *globalOptions) *cobra.Command { + command := &cobra.Command{ + Use: "federated", Aliases: []string{"ocm"}, + Short: "Share with an accepted federated user", + } + command.AddCommand( + newShareFederatedAddCommand(options), + newShareFederatedRolesCommand(options), + ) + return command +} + +func newShareFederatedAddCommand(options *globalOptions) *cobra.Command { + var role string + var recipientIsID, dryRun bool + command := &cobra.Command{ + Use: "add REMOTE_PATH RECIPIENT", + Short: "Grant an accepted federated user access to a remote resource", + Args: exactArgs(2), + RunE: func(command *cobra.Command, args []string) error { + return runShare(command, options, app.ShareRequest{ + Operation: app.ShareFederatedAdd, Path: args[0], + Recipient: args[1], RecipientType: "federated", + RecipientIsID: recipientIsID, Role: role, + Federated: true, DryRun: dryRun, + }) + }, + } + command.Flags().StringVar( + &role, "role", "viewer", + "server-advertised federated role name, ID, or unambiguous alias", + ) + command.Flags().BoolVar( + &recipientIsID, "recipient-id", false, + "treat RECIPIENT as an opaque federated Graph ID", + ) + command.Flags().BoolVar( + &dryRun, "dry-run", false, + "resolve the resource, recipient, and role without sharing", + ) + return command +} + +func newShareFederatedRolesCommand(options *globalOptions) *cobra.Command { + return &cobra.Command{ + Use: "roles REMOTE_PATH", + Short: "List server-advertised roles for federated sharing", + Args: exactArgs(1), + RunE: func(command *cobra.Command, args []string) error { + return runShare(command, options, app.ShareRequest{ + Operation: app.ShareRoles, Path: args[0], Federated: true, + }) + }, + } +} + func newShareOverviewCommand(options *globalOptions) *cobra.Command { var direction, state string command := &cobra.Command{ diff --git a/internal/federation/client.go b/internal/federation/client.go new file mode 100644 index 0000000..56e6664 --- /dev/null +++ b/internal/federation/client.go @@ -0,0 +1,201 @@ +// Package federation implements the authenticated ScienceMesh endpoints used +// to establish and manage oCIS Open Cloud Mesh connections. +package federation + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/mzner/ocis-cli/internal/httpapi" +) + +const maxResponseBytes = 8 << 20 + +// Invitation is a short-lived token another oCIS user can accept to establish +// a federated connection. +type Invitation struct { + Token string `json:"token"` + Description string `json:"description,omitempty"` + Expiration int64 `json:"expiration,omitempty"` + InviteLink string `json:"inviteLink,omitempty"` +} + +// Connection identifies a remote user who has accepted a federation invite. +type Connection struct { + DisplayName string `json:"displayName"` + Provider string `json:"provider"` + UserID string `json:"userId"` + Mail string `json:"mail,omitempty"` +} + +// CreateInvitationRequest describes a new federation invitation. +type CreateInvitationRequest struct { + Recipient string `json:"recipient,omitempty"` + Description string `json:"description,omitempty"` +} + +// AcceptInvitationRequest identifies the invitation and issuing provider. +type AcceptInvitationRequest struct { + Token string `json:"token"` + ProviderDomain string `json:"providerDomain"` +} + +// DeleteConnectionRequest identifies one accepted remote user. +type DeleteConnectionRequest struct { + Provider string `json:"idp"` + UserID string `json:"user_id"` +} + +// Client manages ScienceMesh federation invitations and connections. +type Client struct { + api *httpapi.Client +} + +// NewClient constructs a federation client. +func NewClient(config httpapi.Config, httpClient *http.Client) *Client { + return &Client{api: httpapi.NewClient(config, httpClient)} +} + +// CreateInvitation generates a short-lived federation invitation. +func (client *Client) CreateInvitation( + ctx context.Context, request CreateInvitationRequest, +) (Invitation, error) { + var invitation rawInvitation + if err := client.doJSON( + ctx, http.MethodPost, "/sciencemesh/generate-invite", request, + &invitation, "create federation invitation", + ); err != nil { + return Invitation{}, err + } + return invitation.invitation(), nil +} + +// ListInvitations returns active invitations created by the current user. +func (client *Client) ListInvitations( + ctx context.Context, +) ([]Invitation, error) { + var values []rawInvitation + if err := client.doJSON( + ctx, http.MethodGet, "/sciencemesh/list-invite", nil, + &values, "list federation invitations", + ); err != nil { + return nil, err + } + result := make([]Invitation, 0, len(values)) + for _, value := range values { + result = append(result, value.invitation()) + } + return result, nil +} + +// AcceptInvitation establishes a connection with the invitation issuer. +func (client *Client) AcceptInvitation( + ctx context.Context, request AcceptInvitationRequest, +) error { + if strings.TrimSpace(request.Token) == "" || + strings.TrimSpace(request.ProviderDomain) == "" { + return fmt.Errorf("invitation token and provider domain must not be empty") + } + return client.doJSON( + ctx, http.MethodPost, "/sciencemesh/accept-invite", request, + nil, "accept federation invitation", + ) +} + +// ListConnections returns remote users connected to the current user. +func (client *Client) ListConnections( + ctx context.Context, +) ([]Connection, error) { + var values []rawConnection + if err := client.doJSON( + ctx, http.MethodGet, "/sciencemesh/find-accepted-users", nil, + &values, "list federation connections", + ); err != nil { + return nil, err + } + result := make([]Connection, 0, len(values)) + for _, value := range values { + result = append(result, value.connection()) + } + return result, nil +} + +// DeleteConnection removes one accepted remote-user connection. +func (client *Client) DeleteConnection( + ctx context.Context, request DeleteConnectionRequest, +) error { + if strings.TrimSpace(request.Provider) == "" || + strings.TrimSpace(request.UserID) == "" { + return fmt.Errorf("connection provider and user ID must not be empty") + } + return client.doJSON( + ctx, http.MethodDelete, "/sciencemesh/delete-accepted-user", request, + nil, "delete federation connection", + ) +} + +func (client *Client) doJSON( + ctx context.Context, + method string, + resource string, + payload any, + result any, + operation string, +) error { + var body []byte + var err error + if payload != nil { + body, err = json.Marshal(payload) + if err != nil { + return fmt.Errorf("encode %s request: %w", operation, err) + } + } + headers := http.Header{"Accept": {"application/json"}} + if payload != nil { + headers.Set("Content-Type", "application/json") + } + response, err := client.api.Do(ctx, method, resource, body, headers) + if err != nil { + return err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return httpapi.ResponseError(response) + } + if result == nil { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes)) + return nil + } + if err := json.NewDecoder( + io.LimitReader(response.Body, maxResponseBytes), + ).Decode(result); err != nil { + return fmt.Errorf("decode %s response: %w", operation, err) + } + return nil +} + +type rawInvitation struct { + Token string `json:"token"` + Description string `json:"description,omitempty"` + Expiration int64 `json:"expiration,omitempty"` + InviteLink string `json:"invite_link,omitempty"` +} + +func (value rawInvitation) invitation() Invitation { + return Invitation(value) +} + +type rawConnection struct { + DisplayName string `json:"display_name"` + Provider string `json:"idp"` + UserID string `json:"user_id"` + Mail string `json:"mail"` +} + +func (value rawConnection) connection() Connection { + return Connection(value) +} diff --git a/internal/federation/client_test.go b/internal/federation/client_test.go new file mode 100644 index 0000000..872106a --- /dev/null +++ b/internal/federation/client_test.go @@ -0,0 +1,105 @@ +package federation + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mzner/ocis-cli/internal/httpapi" +) + +func TestInvitationAndConnectionLifecycle(t *testing.T) { + var accepted, deleted bool + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + if request.Header.Get("Authorization") != "Bearer token" { + t.Fatalf("authorization: %q", request.Header.Get("Authorization")) + } + switch request.Method + " " + request.URL.Path { + case "POST /sciencemesh/generate-invite": + var body CreateInvitationRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Recipient != "bob@example.test" || body.Description != "Work" { + t.Fatalf("body: %#v", body) + } + _, _ = io.WriteString(writer, `{ + "token":"invite-token","description":"Work", + "expiration":1786291200,"invite_link":"https://mesh.test/invite" + }`) + case "GET /sciencemesh/list-invite": + _, _ = io.WriteString(writer, `[{"token":"invite-token","expiration":1786291200}]`) + case "POST /sciencemesh/accept-invite": + var body AcceptInvitationRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + accepted = body.Token == "invite-token" && + body.ProviderDomain == "cloud.example.test" + case "GET /sciencemesh/find-accepted-users": + _, _ = io.WriteString(writer, `[{ + "display_name":"Bob","idp":"https://cloud.example.test", + "user_id":"federated-id","mail":"bob@example.test" + }]`) + case "DELETE /sciencemesh/delete-accepted-user": + var body DeleteConnectionRequest + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + deleted = body.Provider == "https://cloud.example.test" && + body.UserID == "federated-id" + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + })) + defer server.Close() + client := NewClient(httpapi.Config{ + Server: server.URL, AuthType: "oidc", AccessToken: "token", + }, server.Client()) + ctx := context.Background() + created, err := client.CreateInvitation(ctx, CreateInvitationRequest{ + Recipient: "bob@example.test", Description: "Work", + }) + if err != nil || created.Token != "invite-token" || + created.InviteLink != "https://mesh.test/invite" { + t.Fatalf("created: %#v, %v", created, err) + } + invites, err := client.ListInvitations(ctx) + if err != nil || len(invites) != 1 || invites[0].Token != "invite-token" { + t.Fatalf("invites: %#v, %v", invites, err) + } + if err := client.AcceptInvitation(ctx, AcceptInvitationRequest{ + Token: "invite-token", ProviderDomain: "cloud.example.test", + }); err != nil || !accepted { + t.Fatalf("accepted=%t error=%v", accepted, err) + } + connections, err := client.ListConnections(ctx) + if err != nil || len(connections) != 1 || + connections[0].UserID != "federated-id" { + t.Fatalf("connections: %#v, %v", connections, err) + } + if err := client.DeleteConnection(ctx, DeleteConnectionRequest{ + Provider: "https://cloud.example.test", UserID: "federated-id", + }); err != nil || !deleted { + t.Fatalf("deleted=%t error=%v", deleted, err) + } +} + +func TestFederationClientRejectsIncompleteMutations(t *testing.T) { + client := NewClient(httpapi.Config{Server: "http://127.0.0.1:1"}, nil) + if err := client.AcceptInvitation( + context.Background(), AcceptInvitationRequest{}, + ); err == nil { + t.Fatal("accepted an empty invitation") + } + if err := client.DeleteConnection( + context.Background(), DeleteConnectionRequest{}, + ); err == nil { + t.Fatal("deleted an empty connection") + } +} diff --git a/internal/graph/directory.go b/internal/graph/directory.go index bd15d68..72c8dd4 100644 --- a/internal/graph/directory.go +++ b/internal/graph/directory.go @@ -11,16 +11,23 @@ import ( // DirectoryUser is the non-sensitive identity data returned by user search. type DirectoryUser struct { - ID string `json:"id,omitempty"` - DisplayName string `json:"displayName,omitempty"` - Username string `json:"onPremisesSamAccountName,omitempty"` - Mail string `json:"mail,omitempty"` - UserType string `json:"userType,omitempty"` - AccountEnabled *bool `json:"accountEnabled,omitempty"` - GivenName string `json:"givenName,omitempty"` - Surname string `json:"surname,omitempty"` - PreferredLanguage string `json:"preferredLanguage,omitempty"` - Attributes []string `json:"attributes,omitempty"` + ID string `json:"id,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Username string `json:"onPremisesSamAccountName,omitempty"` + Mail string `json:"mail,omitempty"` + UserType string `json:"userType,omitempty"` + AccountEnabled *bool `json:"accountEnabled,omitempty"` + GivenName string `json:"givenName,omitempty"` + Surname string `json:"surname,omitempty"` + PreferredLanguage string `json:"preferredLanguage,omitempty"` + Attributes []string `json:"attributes,omitempty"` + Identities []ObjectIdentity `json:"identities,omitempty"` +} + +// ObjectIdentity identifies a user at an identity provider. +type ObjectIdentity struct { + Issuer string `json:"issuer,omitempty"` + IssuerAssignedID string `json:"issuerAssignedId,omitempty"` } // DirectoryGroup is the non-sensitive identity data returned by group search. @@ -157,6 +164,35 @@ func (client *Client) SearchUsers( return result.Value, nil } +// SearchFederatedUsers searches only previously accepted OCM connections. +// Current oCIS deliberately omits federated users unless this exact userType +// filter is present. +func (client *Client) SearchFederatedUsers( + ctx context.Context, search string, +) ([]DirectoryUser, error) { + var result struct { + Value []DirectoryUser `json:"value"` + } + expression, err := directorySearchExpression(DirectorySearch{ + Value: search, Mode: DirectorySearchLiteral, + }) + if err != nil { + return nil, err + } + query := url.Values{} + if expression != "" { + query.Set("$search", expression) + } + query.Set("$filter", "userType eq 'Federated'") + if err := client.doJSON( + ctx, http.MethodGet, "/graph/v1.0/users?"+query.Encode(), + nil, nil, &result, "search federated users", + ); err != nil { + return nil, err + } + return result.Value, nil +} + // SearchGroups searches the oCIS identity directory. func (client *Client) SearchGroups( ctx context.Context, search string, diff --git a/internal/graph/directory_test.go b/internal/graph/directory_test.go index cb60c97..b2d4288 100644 --- a/internal/graph/directory_test.go +++ b/internal/graph/directory_test.go @@ -53,6 +53,33 @@ func TestSearchDirectory(t *testing.T) { } } +func TestSearchFederatedUsersUsesRequiredFilter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + if request.URL.Path != "/graph/v1.0/users" || + request.URL.Query().Get("$search") != `"bob@example.test"` || + request.URL.Query().Get("$filter") != "userType eq 'Federated'" { + t.Fatalf("request: %s?%s", request.URL.Path, request.URL.RawQuery) + } + _, _ = io.WriteString(writer, `{"value":[{ + "id":"federated-id","displayName":"Bob","userType":"Federated", + "identities":[{"issuer":"https://remote.test", + "issuerAssignedId":"remote-id"}] + }]}`) + })) + defer server.Close() + client := NewClient(httpapi.Config{Server: server.URL}, server.Client()) + users, err := client.SearchFederatedUsers( + context.Background(), "bob@example.test", + ) + if err != nil || len(users) != 1 || users[0].UserType != "Federated" || + len(users[0].Identities) != 1 || + users[0].Identities[0].IssuerAssignedID != "remote-id" { + t.Fatalf("users: %#v, %v", users, err) + } +} + func TestAdministrativeDirectoryReads(t *testing.T) { var requests []string server := httptest.NewServer(http.HandlerFunc(func( diff --git a/internal/graph/item_permissions.go b/internal/graph/item_permissions.go index e2ca5b4..6e1f555 100644 --- a/internal/graph/item_permissions.go +++ b/internal/graph/item_permissions.go @@ -13,11 +13,33 @@ import ( // file or folder. func (client *Client) ListItemPermissions( ctx context.Context, resourceID string, +) (Permissions, error) { + return client.listItemPermissions(ctx, resourceID, false) +} + +// ListFederatedItemPermissions returns the roles the server allows for an OCM +// recipient. These can differ from roles advertised for local users. +func (client *Client) ListFederatedItemPermissions( + ctx context.Context, resourceID string, +) (Permissions, error) { + return client.listItemPermissions(ctx, resourceID, true) +} + +func (client *Client) listItemPermissions( + ctx context.Context, resourceID string, federated bool, ) (Permissions, error) { resource, err := itemPermissionsResource(resourceID) if err != nil { return Permissions{}, err } + if federated { + query := url.Values{} + query.Set( + "$filter", + `@libre.graph.permissions.roles.allowedValues/rolePermissions/any(p:contains(p/condition, '@Subject.UserType=="Federated"'))`, + ) + resource += "?" + query.Encode() + } var permissions Permissions if err := client.doJSON( ctx, http.MethodGet, resource, nil, nil, &permissions, diff --git a/internal/graph/item_permissions_test.go b/internal/graph/item_permissions_test.go index 2da1f32..e932392 100644 --- a/internal/graph/item_permissions_test.go +++ b/internal/graph/item_permissions_test.go @@ -98,6 +98,31 @@ func TestItemPermissionLifecycle(t *testing.T) { } } +func TestListFederatedItemPermissionsUsesRoleFilter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + want := `@libre.graph.permissions.roles.allowedValues/rolePermissions/any(p:contains(p/condition, '@Subject.UserType=="Federated"'))` + if request.URL.Query().Get("$filter") != want { + t.Fatalf("filter: %q", request.URL.Query().Get("$filter")) + } + _, _ = io.WriteString(writer, `{ + "@libre.graph.permissions.roles.allowedValues":[{ + "id":"viewer-id","displayName":"Can view" + }],"value":[] + }`) + })) + defer server.Close() + client := NewClient(httpapi.Config{Server: server.URL}, server.Client()) + permissions, err := client.ListFederatedItemPermissions( + context.Background(), "storage$space!file", + ) + if err != nil || len(permissions.AllowedRoles) != 1 || + permissions.AllowedRoles[0].ID != "viewer-id" { + t.Fatalf("permissions: %#v, %v", permissions, err) + } +} + func TestPublicLinkPermissionLifecycle(t *testing.T) { var patched, passwordSet bool server := httptest.NewServer(http.HandlerFunc(func( diff --git a/internal/sharing/client.go b/internal/sharing/client.go index eadba5e..770bdd4 100644 --- a/internal/sharing/client.go +++ b/internal/sharing/client.go @@ -75,6 +75,10 @@ type Capabilities struct { Enabled bool `json:"enabled"` } `json:"expireDate"` } `json:"public"` + Federation struct { + Outgoing bool `json:"outgoing"` + Incoming bool `json:"incoming"` + } `json:"federation"` } `json:"sharing"` Spaces struct { Enabled bool `json:"enabled"` @@ -276,6 +280,10 @@ func (client *Client) Capabilities(ctx context.Context) (Capabilities, error) { Enabled bool `json:"enabled"` } `json:"expire_date"` } `json:"public"` + Federation struct { + Outgoing bool `json:"outgoing"` + Incoming bool `json:"incoming"` + } `json:"federation"` } `json:"files_sharing"` Spaces struct { Enabled bool `json:"enabled"` @@ -318,6 +326,10 @@ func (client *Client) Capabilities(ctx context.Context) (Capabilities, error) { raw.Capabilities.FilesSharing.Public.Password.Enforced result.Sharing.Public.ExpireDate.Enabled = raw.Capabilities.FilesSharing.Public.ExpireDate.Enabled + result.Sharing.Federation.Outgoing = + raw.Capabilities.FilesSharing.Federation.Outgoing + result.Sharing.Federation.Incoming = + raw.Capabilities.FilesSharing.Federation.Incoming result.Spaces.Enabled = raw.Capabilities.Spaces.Enabled result.Spaces.Projects = raw.Capabilities.Spaces.Projects result.Spaces.Version = raw.Capabilities.Spaces.Version diff --git a/internal/sharing/client_test.go b/internal/sharing/client_test.go index 39045b7..cc84eae 100644 --- a/internal/sharing/client_test.go +++ b/internal/sharing/client_test.go @@ -90,7 +90,8 @@ func TestCapabilities(t *testing.T) { "max_chunk_size":10000000,"http_method_override":"true" }}, "files_sharing":{"api_enabled":true,"group_sharing":true, - "sharing_roles":true,"public":{ + "sharing_roles":true, + "federation":{"outgoing":true,"incoming":true},"public":{ "enabled":true,"password":{"enforced":true}, "expire_date":{"enabled":true} }}, @@ -112,6 +113,8 @@ func TestCapabilities(t *testing.T) { capabilities.DAV.Reports[0] != "search-files" || !capabilities.Sharing.GroupEnabled || !capabilities.Sharing.SharingRoles || + !capabilities.Sharing.Federation.Outgoing || + !capabilities.Sharing.Federation.Incoming || !capabilities.Sharing.Public.Password.Enforced || capabilities.Files.TUS.MaxChunkSize != 10000000 || len(capabilities.Files.TUS.Extensions) != 2 || diff --git a/internal/sharing/direct.go b/internal/sharing/direct.go index eafd62e..8575840 100644 --- a/internal/sharing/direct.go +++ b/internal/sharing/direct.go @@ -40,14 +40,17 @@ type ShareListRequest struct { AllStates bool } -// ListShares returns outgoing user, group, and public-link shares or received -// user and group shares. +// ListShares returns outgoing user, group, federated, and public-link shares or +// received user, group, and federated shares. func (client *Client) ListShares( ctx context.Context, request ShareListRequest, ) ([]Share, error) { query := url.Values{"format": {"json"}} if request.Received { query.Set("shared_with_me", "true") + // Current oCIS appends enabled OCM shares independently of the legacy + // user/group filter. Keeping 0,1 preserves compatibility with older OCS + // handlers while share type 6 is decoded below when returned. query.Set("share_types", "0,1") if request.AllStates { query.Set("state", "all") @@ -189,6 +192,8 @@ func shareTypeName(value string) string { return "group" case "3", "public_link": return "public_link" + case "6", "federated": + return "federated" default: return value } diff --git a/internal/sharing/direct_test.go b/internal/sharing/direct_test.go index e574d0a..d659847 100644 --- a/internal/sharing/direct_test.go +++ b/internal/sharing/direct_test.go @@ -110,3 +110,9 @@ func TestShareStateName(t *testing.T) { } } } + +func TestFederatedShareTypeName(t *testing.T) { + if got := shareTypeName("6"); got != "federated" { + t.Fatalf("type: got %q, want federated", got) + } +}