Table Access Permissions
SpacetimeDB controls data access through table visibility and context-based permissions. Tables can be public or private, and different execution contexts (reducers, views, clients) have different levels of access.
Public and Private Tables
Tables are private by default. Private tables can only be accessed by reducers and views running on the server. Clients cannot query, subscribe to, or see private tables.
Public tables are exposed to clients for read access through subscriptions and queries. Clients can see public table data but can only modify it by calling reducers.
- TypeScript
- C#
- Rust
// Private table (default) - only accessible from server-side code
const internalConfig = table(
{ name: 'internal_config' },
{
key: t.string().primaryKey(),
value: t.string(),
}
);
// Public table - clients can subscribe and query
const player = table(
{ name: 'player', public: true },
{
id: t.u64().primaryKey().autoInc(),
name: t.string(),
score: t.u64(),
}
);// Private table (default) - only accessible from server-side code
[SpacetimeDB.Table(Name = "InternalConfig")]
public partial struct InternalConfig
{
[SpacetimeDB.PrimaryKey]
public string Key;
public string Value;
}
// Public table - clients can subscribe and query
[SpacetimeDB.Table(Name = "Player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public string Name;
public ulong Score;
}// Private table (default) - only accessible from server-side code
#[spacetimedb::table(name = internal_config)]
pub struct InternalConfig {
#[primary_key]
key: String,
value: String,
}
// Public table - clients can subscribe and query
#[spacetimedb::table(name = player, public)]
pub struct Player {
#[primary_key]
#[auto_inc]
id: u64,
name: String,
score: u64,
}Use private tables for:
- Internal configuration or state that clients should not see
- Sensitive data like password hashes or API keys
- Intermediate computation results
Use public tables for:
- Data that clients need to display or interact with
- Game state, user profiles, or other user-facing data
Reducers - Read-Write Access
Reducers receive a ReducerContext which provides full read-write access to all tables (both public and private). They can perform all CRUD operations: insert, read, update, and delete.
- TypeScript
- C#
- Rust
spacetimedb.reducer('example', {}, (ctx) => {
// Insert
ctx.db.user.insert({ id: 0, name: 'Alice', email: 'alice@example.com' });
// Read: iterate all rows
for (const user of ctx.db.user.iter()) {
console.log(user.name);
}
// Read: find by unique column
const foundUser = ctx.db.user.id.find(123);
if (foundUser) {
// Update
foundUser.name = 'Bob';
ctx.db.user.id.update(foundUser);
}
// Delete
ctx.db.user.id.delete(456);
});[SpacetimeDB.Reducer]
public static void Example(ReducerContext ctx)
{
// Insert
ctx.Db.User.Insert(new User { Id = 0, Name = "Alice", Email = "alice@example.com" });
// Read: iterate all rows
foreach (var user in ctx.Db.User.Iter())
{
Log.Info($"User: {user.Name}");
}
// Read: find by unique column
if (ctx.Db.User.Id.Find(123) is User foundUser)
{
// Update
foundUser.Name = "Bob";
ctx.Db.User.Id.Update(foundUser);
}
// Delete
ctx.Db.User.Id.Delete(456);
}#[spacetimedb::reducer]
fn example(ctx: &ReducerContext) -> Result<(), String> {
// Insert
ctx.db.user().insert(User {
id: 0,
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
});
// Read: iterate all rows
for user in ctx.db.user().iter() {
log::info!("User: {}", user.name);
}
// Read: find by unique column
if let Some(mut user) = ctx.db.user().id().find(123) {
// Update
user.name = "Bob".to_string();
ctx.db.user().id().update(user);
}
// Delete
ctx.db.user().id().delete(456);
Ok(())
}Procedures with Transactions - Read-Write Access
Procedures receive a ProcedureContext and can access tables through transactions. Unlike reducers, procedures must explicitly open a transaction to read from or modify the database.
- TypeScript
- C#
- Rust
spacetimedb.procedure('updateUserProcedure', { userId: t.u64(), newName: t.string() }, t.unit(), (ctx, { userId, newName }) => {
// Must explicitly open a transaction
ctx.withTx(ctx => {
// Full read-write access within the transaction
const user = ctx.db.user.id.find(userId);
if (user) {
user.name = newName;
ctx.db.user.id.update(user);
}
});
// Transaction is committed when the function returns
return {};
});
#pragma warning disable STDB_UNSTABLE
[SpacetimeDB.Procedure]
public static void UpdateUserProcedure(ProcedureContext ctx, ulong userId, string newName)
{
// Must explicitly open a transaction
ctx.WithTx(txCtx =>
{
// Full read-write access within the transaction
var user = txCtx.Db.User.Id.Find(userId);
if (user != null)
{
var updated = user.Value;
updated.Name = newName;
txCtx.Db.User.Id.Update(updated);
}
return 0;
});
// Transaction is committed when the lambda returns
}#[spacetimedb::procedure]
fn update_user_procedure(ctx: &mut ProcedureContext, user_id: u64, new_name: String) {
// Must explicitly open a transaction
ctx.with_tx(|ctx| {
// Full read-write access within the transaction
if let Some(mut user) = ctx.db.user().id().find(user_id) {
user.name = new_name.clone();
ctx.db.user().id().update(user);
}
});
// Transaction is committed when the closure returns
}See the Procedures documentation for more details on using procedures, including making HTTP requests to external services.
Views - Read-Only Access
Views receive a ViewContext or AnonymousViewContext which provides read-only access to all tables (both public and private). They can query and iterate tables, but cannot insert, update, or delete rows.
- TypeScript
- C#
- Rust
spacetimedb.view(
{ name: 'findUsersByName', public: true },
t.array(user.rowType),
(ctx) => {
// Can read and filter
return Array.from(ctx.db.user.name.filter('Alice'));
// Cannot insert, update, or delete
// ctx.db.user.insert(...) // ❌ Method not available
});[SpacetimeDB.View(Name = "FindUsersByName", Public = true)]
public static List<User> FindUsersByName(ViewContext ctx)
{
// Can read and filter
return ctx.Db.User.Name.Filter("Alice").ToList();
// Cannot insert, update, or delete
// ctx.Db.User.Insert(...) // ❌ Method not available
}#[spacetimedb::view(name = find_users_by_name, public)]
fn find_users_by_name(ctx: &ViewContext) -> Vec<User> {
// Can read and filter
ctx.db.user().name().filter("Alice").collect()
// Cannot insert, update, or delete
// ctx.db.user().insert(...) // ❌ Compile error
}See the Views documentation for more details on defining and querying views.
Using Views for Fine-Grained Access Control
While table visibility controls whether clients can access a table at all, views provide fine-grained control over which rows and columns clients can see. Views can read from private tables and expose only the data appropriate for each client.
Views can only access table data through indexed lookups, not by scanning all rows. This restriction ensures views remain performant. See the Views documentation for details.
Filtering Rows by Caller
Use views with ViewContext to return only the rows that belong to the caller. The view accesses the caller's identity through ctx.sender() and uses it to look up rows via an index.
- TypeScript
- C#
- Rust
import { table, t, schema } from 'spacetimedb/server';
// Private table containing all messages
const message = table(
{ name: 'message' }, // Private by default
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity().index('btree'),
recipient: t.identity().index('btree'),
content: t.string(),
timestamp: t.timestamp(),
}
);
const spacetimedb = schema(message);
// Public view that only returns messages the caller can see
spacetimedb.view(
{ name: 'my_messages', public: true },
t.array(message.rowType),
(ctx) => {
// Look up messages by index where caller is sender or recipient
const sent = Array.from(ctx.db.message.sender.filter(ctx.sender));
const received = Array.from(ctx.db.message.recipient.filter(ctx.sender));
return [...sent, ...received];
}
);using SpacetimeDB;
public partial class Module
{
// Private table containing all messages
[SpacetimeDB.Table(Name = "Message")] // Private by default
public partial struct Message
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Index.BTree]
public Identity Sender;
[SpacetimeDB.Index.BTree]
public Identity Recipient;
public string Content;
public Timestamp Timestamp;
}
// Public view that only returns messages the caller can see
[SpacetimeDB.View(Name = "MyMessages", Public = true)]
public static List<Message> MyMessages(ViewContext ctx)
{
// Look up messages by index where caller is sender or recipient
var sent = ctx.Db.Message.Sender.Filter(ctx.Sender).ToList();
var received = ctx.Db.Message.Recipient.Filter(ctx.Sender).ToList();
sent.AddRange(received);
return sent;
}
use spacetimedb::{Identity, Timestamp, ViewContext};
// Private table containing all messages
#[spacetimedb::table(name = message)] // Private by default
pub struct Message {
#[primary_key]
#[auto_inc]
id: u64,
#[index(btree)]
sender: Identity,
#[index(btree)]
recipient: Identity,
content: String,
timestamp: Timestamp,
}
// Public view that only returns messages the caller can see
#[spacetimedb::view(name = my_messages, public)]
fn my_messages(ctx: &ViewContext) -> Vec<Message> {
// Look up messages by index where caller is sender or recipient
let sent: Vec<_> = ctx.db.message().sender().filter(&ctx.sender()).collect();
let received: Vec<_> = ctx.db.message().recipient().filter(&ctx.sender()).collect();
sent.into_iter().chain(received).collect()
}Clients querying my_messages will only see their own messages, even though all messages are stored in the same table.
Hiding Sensitive Columns
Use views to return a custom type that omits sensitive columns. The view reads from a table with sensitive data and returns a projection containing only the columns clients should see.
- TypeScript
- C#
- Rust
import {schema, t, table} from 'spacetimedb/server';
// Private table with sensitive data
const userAccount = table(
{ name: 'user_account' }, // Private by default
{
id: t.u64().primaryKey().autoInc(),
identity: t.identity().unique(),
username: t.string(),
email: t.string(),
passwordHash: t.string(), // Sensitive
apiKey: t.string(), // Sensitive
createdAt: t.timestamp(),
}
);
const spacetimedb = schema(userAccount);
// Public type without sensitive columns
const publicUserProfile = t.row('PublicUserProfile', {
id: t.u64(),
username: t.string(),
createdAt: t.timestamp(),
});
// Public view that returns the caller's profile without sensitive data
spacetimedb.view(
{ name: 'my_profile', public: true },
t.option(publicUserProfile),
(ctx) => {
// Look up the caller's account by their identity (unique index)
const user = ctx.db.userAccount.identity.find(ctx.sender);
if (!user) return null;
return {
id: user.id,
username: user.username,
createdAt: user.createdAt,
// email, passwordHash, and apiKey are not included
};
}
);using SpacetimeDB;
public partial class Module
{
// Private table with sensitive data
[SpacetimeDB.Table(Name = "UserAccount")] // Private by default
public partial struct UserAccount
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Unique]
public Identity Identity;
public string Username;
public string Email;
public string PasswordHash; // Sensitive
public string ApiKey; // Sensitive
public Timestamp CreatedAt;
}
// Public type without sensitive columns
[SpacetimeDB.Type]
public partial struct PublicUserProfile
{
public ulong Id;
public string Username;
public Timestamp CreatedAt;
}
// Public view that returns the caller's profile without sensitive data
[SpacetimeDB.View(Name = "MyProfile", Public = true)]
public static PublicUserProfile? MyProfile(ViewContext ctx)
{
// Look up the caller's account by their identity (unique index)
if (ctx.Db.UserAccount.Identity.Find(ctx.Sender) is not UserAccount user)
{
return null;
}
return new PublicUserProfile
{
Id = user.Id,
Username = user.Username,
CreatedAt = user.CreatedAt,
// Email, PasswordHash, and ApiKey are not included
};
}
}use spacetimedb::{SpacetimeType, ViewContext, Timestamp, Identity};
// Private table with sensitive data
#[spacetimedb::table(name = user_account)] // Private by default
pub struct UserAccount {
#[primary_key]
#[auto_inc]
id: u64,
#[unique]
identity: Identity,
username: String,
email: String,
password_hash: String, // Sensitive
api_key: String, // Sensitive
created_at: Timestamp,
}
// Public type without sensitive columns
#[derive(SpacetimeType)]
pub struct PublicUserProfile {
id: u64,
username: String,
created_at: Timestamp,
}
// Public view that returns the caller's profile without sensitive data
#[spacetimedb::view(name = my_profile, public)]
fn my_profile(ctx: &ViewContext) -> Option<PublicUserProfile> {
// Look up the caller's account by their identity (unique index)
let user = ctx.db.user_account().identity().find(&ctx.sender())?;
Some(PublicUserProfile {
id: user.id,
username: user.username,
created_at: user.created_at,
// email, password_hash, and api_key are not included
})
}Clients can query my_profile to see their username and creation date, but never see their email address, password hash, or API key.
Combining Both Techniques
Views can combine row filtering and column projection. This example returns colleagues in the same department as the caller, with salary information hidden:
- TypeScript
- C#
- Rust
import { table, t, schema } from 'spacetimedb/server';
// Private table with all employee data
const employee = table(
{ name: 'employee' },
{
id: t.u64().primaryKey(),
identity: t.identity().unique(),
name: t.string(),
department: t.string().index('btree'),
salary: t.u64(), // Sensitive
}
);
const spacetimedb = schema(employee);
// Public type for colleagues (no salary)
const colleague = t.row('Colleague', {
id: t.u64(),
name: t.string(),
department: t.string(),
});
// View that returns colleagues in the caller's department, without salary info
spacetimedb.view(
{ name: 'my_colleagues', public: true },
t.array(colleague),
(ctx) => {
// Find the caller's employee record by identity (unique index)
const me = ctx.db.employee.identity.find(ctx.sender);
if (!me) return [];
// Look up employees in the same department
return Array.from(ctx.db.employee.department.filter(me.department)).map(emp => ({
id: emp.id,
name: emp.name,
department: emp.department,
// salary is not included
}));
}
);using SpacetimeDB;
public partial class Module
{
// Private table with all employee data
[SpacetimeDB.Table(Name = "Employee")]
public partial struct Employee
{
[SpacetimeDB.PrimaryKey]
public ulong Id;
[SpacetimeDB.Unique]
public Identity Identity;
public string Name;
[SpacetimeDB.Index.BTree]
public string Department;
public ulong Salary; // Sensitive
}
// Public type for colleagues (no salary)
[SpacetimeDB.Type]
public partial struct Colleague
{
public ulong Id;
public string Name;
public string Department;
}
// View that returns colleagues in the caller's department, without salary info
[SpacetimeDB.View(Name = "MyColleagues", Public = true)]
public static List<Colleague> MyColleagues(ViewContext ctx)
{
// Find the caller's employee record by identity (unique index)
if (ctx.Db.Employee.Identity.Find(ctx.Sender) is not Employee me)
{
return new List<Colleague>();
}
// Look up employees in the same department
return ctx.Db.Employee.Department.Filter(me.Department)
.Select(emp => new Colleague
{
Id = emp.Id,
Name = emp.Name,
Department = emp.Department,
// Salary is not included
})
.ToList();
}
}use spacetimedb::{SpacetimeType, Identity, ViewContext};
// Private table with all employee data
#[spacetimedb::table(name = employee)]
pub struct Employee {
#[primary_key]
id: u64,
#[unique]
identity: Identity,
name: String,
#[index(btree)]
department: String,
salary: u64, // Sensitive
}
// Public type for colleagues (no salary)
#[derive(SpacetimeType)]
pub struct Colleague {
id: u64,
name: String,
department: String,
}
// View that returns colleagues in the caller's department, without salary info
#[spacetimedb::view(name = my_colleagues, public)]
fn my_colleagues(ctx: &ViewContext) -> Vec<Colleague> {
// Find the caller's employee record by identity (unique index)
let Some(me) = ctx.db.employee().identity().find(&ctx.sender()) else {
return vec![];
};
// Look up employees in the same department
ctx.db.employee().department().filter(&me.department)
.map(|emp| Colleague {
id: emp.id,
name: emp.name.clone(),
department: emp.department.clone(),
// salary is not included
})
.collect()
}Client Access - Read-Only Access
Clients connect to databases and can access public tables and views through subscriptions and queries. They cannot access private tables directly. See the Subscriptions documentation for details on client-side table access.