Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ after it. Pass it explicitly to override for a single command.
## Commands

Aliases: `ws` = `workspace`, `proj` = `project`, `lib` = `library`,
`doc` = `document`, `conv` = `conversation`, `msg` = `message`.
`doc` = `document`, `conv` = `conversation`, `msg` = `message`,
`key` = `api-key`, `invite` = `invitation`.

### Auth

Expand Down Expand Up @@ -340,6 +341,48 @@ ranked list. Filters take one comma-separated value each (`--projects a,b`), and
omitting a filter searches everything in that dimension. `--top-k` caps results
per type. There is no pagination.

### Team management

The team your API key belongs to — its API keys, members, invitations and
usage — is managed with the same key and endpoint as everything above. The team
is fixed by the key: nothing here takes a team parameter, and each command is
authorized by what the key's creator may do in the console.

```bash
memorylake team get
memorylake team rename --name "New Name" # owner only

memorylake key list [--name FUZZY] [--page-size N] [--continuation-token TOKEN]
memorylake key get <id>
memorylake key create --name ci [--member <principal-id>] [--expires-at UNIX_SECONDS]
memorylake key rotate <id>
memorylake key revoke <id>

memorylake member list [--name FUZZY] [--page-size N]
memorylake member create --name "CI Bot" --role tenant_member # virtual member
memorylake member set-role <principal-id> --role tenant_admin
memorylake member remove <principal-id>

memorylake invite create --email person@example.com --role tenant_member
memorylake invite list [--status pending|accepted|rejected|expired|revoked]
memorylake invite revoke <id>

memorylake usage [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD]
```

`key create` and `key rotate` print the full key **exactly once** — list and get
only ever return its prefix, and an idempotent replay omits it too, so capture
it from the first response.

A *virtual member* is a login-less identity for automations: create one with
`member create`, then issue it a key with `key create --member <principal-id>`.
That key acts with the virtual member's role instead of yours, so a CI job can
hold exactly the permissions it needs.

Every write takes `--idempotency-key VALUE`. Retrying with the same value
replays the first result instead of repeating the write — no duplicate key,
member, or invitation email.

## Configuration

Credentials and settings live in `~/.memorylake/` (`credentials.toml`,
Expand Down
1 change: 1 addition & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ ctrlc = { workspace = true }
dialoguer = { workspace = true }
libc = { workspace = true }
memorylake-core = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
Expand Down
134 changes: 134 additions & 0 deletions crates/cli/src/commands/api_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! `memorylake api-key` / `key` commands.

use anyhow::{Context, Result};
use clap::Subcommand;
use memorylake_core::api::admin::{
CreateApiKeyRequest, ListParams, create_api_key, get_api_key, list_api_keys, revoke_api_key,
rotate_api_key,
};

use super::{api_client, print_json};

/// API key subcommands.
#[derive(Debug, Subcommand)]
pub enum ApiKeyCommand {
/// List the team's API keys. The keys themselves are never returned —
/// only their prefixes.
List {
/// Number of items per page.
#[arg(long)]
page_size: Option<u32>,
/// Continuation token from a previous response.
#[arg(long)]
continuation_token: Option<String>,
/// Fuzzy filter by key name (partial match).
#[arg(long = "name")]
name_fuzzy: Option<String>,
},
/// Get a single API key by id.
Get {
/// API key id.
id: String,
},
/// Create an API key. The full key is printed exactly once — save it.
Create {
/// Display name for the key.
#[arg(long)]
name: String,
/// Issue the key for this virtual member (see `member create`); the
/// key then acts as that member. Human members cannot be targeted.
#[arg(long = "member", value_name = "PRINCIPAL_ID")]
member_principal_id: Option<String>,
/// Expiry, Unix seconds. Omit for a key that never expires.
#[arg(long)]
expires_at: Option<i64>,
/// Retrying with the same value replays the first result instead of
/// creating a second key.
#[arg(long)]
idempotency_key: Option<String>,
},
/// Replace a key's material and print the new value once. The previous
/// value stops working immediately.
Rotate {
/// API key id.
id: String,
/// Retrying with the same value replays the first result instead of
/// minting a second key.
#[arg(long)]
idempotency_key: Option<String>,
},
/// Delete an API key. The key making the request cannot revoke itself.
Revoke {
/// API key id.
id: String,
/// Retrying with the same value replays the first result.
#[arg(long)]
idempotency_key: Option<String>,
},
}

/// Execute an `api-key` subcommand.
pub fn run(
command: ApiKeyCommand,
profile: Option<String>,
base_url: Option<String>,
) -> Result<()> {
let client = api_client(profile, base_url)?;

match command {
ApiKeyCommand::List {
page_size,
continuation_token,
name_fuzzy,
} => {
let data = list_api_keys(
&client,
&ListParams {
page_size,
continuation_token,
name_fuzzy,
},
)
.context("list API keys")?;
print_json(&data)
}
ApiKeyCommand::Get { id } => {
let data = get_api_key(&client, &id).context("get API key")?;
print_json(&data)
}
ApiKeyCommand::Create {
name,
member_principal_id,
expires_at,
idempotency_key,
} => {
let data = create_api_key(
&client,
&CreateApiKeyRequest {
name,
member_principal_id,
expires_at,
},
idempotency_key.as_deref(),
)
.context("create API key")?;
print_json(&data)
}
ApiKeyCommand::Rotate {
id,
idempotency_key,
} => {
let data = rotate_api_key(&client, &id, idempotency_key.as_deref())
.context("rotate API key")?;
print_json(&data)
}
ApiKeyCommand::Revoke {
id,
idempotency_key,
} => {
revoke_api_key(&client, &id, idempotency_key.as_deref()).context("revoke API key")?;
println!("API key {id} revoked");
Ok(())
}
}
}
100 changes: 100 additions & 0 deletions crates/cli/src/commands/invitation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! `memorylake invitation` / `invite` commands.

use anyhow::{Context, Result};
use clap::Subcommand;
use memorylake_core::api::admin::{
CreateInvitationRequest, ListInvitationsParams, create_invitation, list_invitations,
revoke_invitation,
};

use super::{api_client, print_json};

/// Invitation subcommands.
#[derive(Debug, Subcommand)]
pub enum InvitationCommand {
/// Invite someone to the team by email. One live invitation per address;
/// re-inviting is revoke + create.
Create {
/// Invitee email address.
#[arg(long)]
email: String,
/// Role on acceptance: tenant_admin, tenant_member, or a custom role
/// key.
#[arg(long)]
role: String,
/// Retrying with the same value replays the first result instead of
/// sending a second email.
#[arg(long)]
idempotency_key: Option<String>,
},
/// List the team's invitations, newest first.
List {
/// Number of items per page.
#[arg(long)]
page_size: Option<u32>,
/// Continuation token from a previous response.
#[arg(long)]
continuation_token: Option<String>,
/// Only this state: pending, accepted, rejected, expired, revoked.
#[arg(long)]
status: Option<String>,
},
/// Revoke a pending invitation; its email link stops working.
Revoke {
/// Invitation id.
id: String,
/// Retrying with the same value replays the first result.
#[arg(long)]
idempotency_key: Option<String>,
},
}

/// Execute an `invitation` subcommand.
pub fn run(
command: InvitationCommand,
profile: Option<String>,
base_url: Option<String>,
) -> Result<()> {
let client = api_client(profile, base_url)?;

match command {
InvitationCommand::Create {
email,
role,
idempotency_key,
} => {
let data = create_invitation(
&client,
&CreateInvitationRequest { email, role },
idempotency_key.as_deref(),
)
.context("create invitation")?;
print_json(&data)
}
InvitationCommand::List {
page_size,
continuation_token,
status,
} => {
let data = list_invitations(
&client,
&ListInvitationsParams {
page_size,
continuation_token,
status,
},
)
.context("list invitations")?;
print_json(&data)
}
InvitationCommand::Revoke {
id,
idempotency_key,
} => {
revoke_invitation(&client, &id, idempotency_key.as_deref())
.context("revoke invitation")?;
println!("invitation {id} revoked");
Ok(())
}
}
}
Loading
Loading