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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ variable added to the base later still arrives. A Room needs a future end time,
`spec.appearance`: a tagline, a picture, an accent colour and a background,
served from the application's own host
([install guide](docs/install.md#dress-the-join-page-for-the-talk-optional)).
With `spec.question` it asks one question at the door, and the `demo:` group of
the answer joins `audienceGroup` in the participant's token
([install guide](docs/install.md#ask-one-question-at-the-door-optional)).
Required cookie Secret:

```sh
Expand Down
59 changes: 59 additions & 0 deletions api/v1alpha1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ type RoomSpec struct {
// invalidates the code on screen. Set it before the event.
// +optional
Appearance *RoomAppearance `json:"appearance,omitempty"`
// Question asks everyone who joins to pick one answer, and the group of
// that answer joins the audienceGroup in their token. Empty, nobody is
// asked and the audienceGroup is their only group, as before.
//
// The participant chooses, so an answer is as unverified as a display
// name: bind its group only to what anyone in the room may choose to have.
//
// The answer is kept on the Participant (spec.groups) at enrollment.
// Changing the question later changes nothing for those already enrolled,
// and nobody who enrolled before it existed is asked. Like any spec
// change, editing it starts a fresh join-code epoch. Set it before the
// event.
// +optional
Question *RoomQuestion `json:"question,omitempty"`
// EndsAt can be extended without changing participant identities.
EndsAt metav1.Time `json:"endsAt"`
// +kubebuilder:default=Closed
Expand Down Expand Up @@ -139,6 +153,38 @@ type RoomAppearance struct {
BackgroundImage string `json:"backgroundImage,omitempty"`
}

// RoomQuestion is the one question a Room asks at the door. Every answer names
// a group, so a group from this list is all a participant can choose: never a
// username, never a group outside demo:, never one the operator did not write.
type RoomQuestion struct {
// Prompt is the question, shown above the answers.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=120
// +kubebuilder:validation:Pattern="^[^<>\\x00-\\x1f\\x7f]*$"
Prompt string `json:"prompt"`
// Answers are shown in this order, and every participant picks one.
// +kubebuilder:validation:MinItems=2
// +kubebuilder:validation:MaxItems=8
// +listType=map
// +listMapKey=group
// +kubebuilder:validation:XValidation:rule="self.all(a, self.exists_one(b, b.label == a.label))",message="answer labels must be unique"
Answers []RoomAnswer `json:"answers"`
}

type RoomAnswer struct {
// Label is what the participant picks, such as Svelte.
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=60
// +kubebuilder:validation:Pattern="^[^<>\\x00-\\x1f\\x7f]*$"
Label string `json:"label"`
// Group is what picking it adds to the participant's groups, such as
// demo:framework-svelte. It follows audienceGroup's rules, so the
// apiserver's demo:-only containment rule covers it unchanged.
// +kubebuilder:validation:Pattern="^demo:[a-zA-Z0-9][a-zA-Z0-9:_-]*$"
// +kubebuilder:validation:MaxLength=128
Group string `json:"group"`
}

type Code struct {
// +kubebuilder:validation:MaxLength=12
Code string `json:"code"`
Expand Down Expand Up @@ -230,6 +276,19 @@ type ParticipantSpec struct {
// +kubebuilder:validation:Pattern="^[A-Za-z0-9]([-A-Za-z0-9]*[A-Za-z0-9])?$"
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="displayName is immutable"
DisplayName string `json:"displayName"`
// Groups are this participant's groups beside the Room's audienceGroup:
// the group of the answer they picked, when the Room asked a question.
// Room Pass sends them to Dex at every sign-in. Like the display name, they
// are the participant's choice, never verified.
//
// An operator may change them. The next sign-in carries the change; a
// token already issued keeps the groups it was issued with.
// +kubebuilder:validation:MaxItems=8
// +kubebuilder:validation:items:Pattern="^demo:[a-zA-Z0-9][a-zA-Z0-9:_-]*$"
// +kubebuilder:validation:items:MaxLength=128
// +listType=set
// +optional
Groups []string `json:"groups,omitempty"`
// +kubebuilder:default=false
// +kubebuilder:validation:XValidation:rule="!oldSelf || self",message="revocation is irreversible"
Revoked bool `json:"revoked"`
Expand Down
47 changes: 46 additions & 1 deletion api/v1alpha1/zz_generated.deepcopy.go

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

16 changes: 16 additions & 0 deletions config/crd/bases/room-pass.koudijs.dev_participants.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ spec:
x-kubernetes-validations:
- message: displayName is immutable
rule: self == oldSelf
groups:
description: |-
Groups are this participant's groups beside the Room's audienceGroup:
the group of the answer they picked, when the Room asked a question.
Room Pass sends them to Dex at every sign-in. Like the display name, they
are the participant's choice, never verified.

An operator may change them. The next sign-in carries the change; a
token already issued keeps the groups it was issued with.
items:
maxLength: 128
pattern: ^demo:[a-zA-Z0-9][a-zA-Z0-9:_-]*$
type: string
maxItems: 8
type: array
x-kubernetes-list-type: set
revoked:
default: false
type: boolean
Expand Down
58 changes: 58 additions & 0 deletions config/crd/bases/room-pass.koudijs.dev_rooms.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,64 @@ spec:
maximum: 10000
minimum: 1
type: integer
question:
description: |-
Question asks everyone who joins to pick one answer, and the group of
that answer joins the audienceGroup in their token. Empty, nobody is
asked and the audienceGroup is their only group, as before.

The participant chooses, so an answer is as unverified as a display
name: bind its group only to what anyone in the room may choose to have.

The answer is kept on the Participant (spec.groups) at enrollment.
Changing the question later changes nothing for those already enrolled,
and nobody who enrolled before it existed is asked. Like any spec
change, editing it starts a fresh join-code epoch. Set it before the
event.
properties:
answers:
description: Answers are shown in this order, and every participant
picks one.
items:
properties:
group:
description: |-
Group is what picking it adds to the participant's groups, such as
demo:framework-svelte. It follows audienceGroup's rules, so the
apiserver's demo:-only containment rule covers it unchanged.
maxLength: 128
pattern: ^demo:[a-zA-Z0-9][a-zA-Z0-9:_-]*$
type: string
label:
description: Label is what the participant picks, such as
Svelte.
maxLength: 60
minLength: 1
pattern: ^[^<>\x00-\x1f\x7f]*$
type: string
required:
- group
- label
type: object
maxItems: 8
minItems: 2
type: array
x-kubernetes-list-map-keys:
- group
x-kubernetes-list-type: map
x-kubernetes-validations:
- message: answer labels must be unique
rule: self.all(a, self.exists_one(b, b.label == a.label))
prompt:
description: Prompt is the question, shown above the answers.
maxLength: 120
minLength: 1
pattern: ^[^<>\x00-\x1f\x7f]*$
type: string
required:
- answers
- prompt
type: object
stopped:
default: false
description: Stopped permanently denies enrollment and new identity
Expand Down
4 changes: 3 additions & 1 deletion deploy/example/dex-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ connectors:
# The only connector Room Pass works with. Its id is part of the identity
# contract: it sets the callback path (/callback/room-pass), Room Pass's
# CONNECTOR_ID default, and the connector the apiserver configuration
# contains. The header names are the ones Room Pass sends.
# contains. The header names are the ones Room Pass sends. It sends a Room's
# audienceGroup and the group of a participant's answer comma-separated, the
# default groupHeaderSeparator: leave that unset.
- type: authproxy
id: room-pass
name: Join the room
Expand Down
10 changes: 10 additions & 0 deletions deploy/example/room.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ spec:
# accentColor: "#f2a541"
# backgroundColor: "#0f3d3e"
# backgroundImage: /talks/my-talk/stage.jpg
# Optional: one question everyone answers when joining. The answer's group
# joins audienceGroup in their token. Participants choose it, so it carries
# no more weight than their name. Set it before the event too.
# question:
# prompt: Your favourite frontend framework?
# answers:
# - label: React
# group: demo:framework-react
# - label: Svelte
# group: demo:framework-svelte
# Nobody can join after this. Move it before every event.
endsAt: "2026-12-31T18:00:00Z"
enrollment: Open
Expand Down
41 changes: 41 additions & 0 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,47 @@ The instructions, the issued address and the notice that a name is an
unverified label stay as Room Pass writes them; `attributionNote` remains the
one sentence an operator adds about identity.

### Ask one question at the door (optional)

`spec.question` asks everyone who joins to pick one answer, and puts that
answer's group in their token beside `audienceGroup`. Bind a permission to one
answer's group and only part of the room has it, until you bind it to
`audienceGroup` as well.

```yaml
spec:
audienceGroup: demo:my-talk
question:
prompt: Your favourite frontend framework?
answers: # 2 to 8, shown in this order
- label: React
group: demo:framework-react
- label: Vue
group: demo:framework-vue
- label: Angular
group: demo:framework-angular
- label: Svelte
group: demo:framework-svelte
```

Somebody who picks Svelte signs in with the groups `demo:my-talk` and
`demo:framework-svelte`.

- **An answer is the participant's word.** Anyone can pick Svelte, so give an
answer's group only what anyone in the room could be allowed to have. Ask
nothing personal: the group travels in the token, into audit events and into
anything that mirrors them.
- **Answer groups follow `audienceGroup`'s rules**: `demo:`, then letters,
digits, `:`, `_` and `-`. The apiserver's containment rule (step 7) already
admits them, so its configuration does not change. Room Pass sends Dex the
groups comma-separated, the authproxy connector's default
`groupHeaderSeparator`; leave that unset.
- **The answer is kept on the Participant**, in `spec.groups`, and sent at every
sign-in. Nobody is asked twice. To move someone, edit their `spec.groups`:
their next sign-in carries it, while a token already issued keeps its groups.
- **Set it before you go on stage.** Editing it starts a fresh code epoch, and
anyone who joined before the question existed is not asked it.

## 6. Connect your application

Your application is an OIDC client of the issuer:
Expand Down
5 changes: 3 additions & 2 deletions docs/metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ metrics. There is no separate telemetry pipeline or per-request Kubernetes scrap
| `room_pass_handoff_slots_used` | How much of the in-memory transaction map is allocated? |
| `room_pass_handoff_capacity` | What is the configured transaction limit? |

Enrollment result values are `enrolled`, `code_or_room_rejected`, `full` and
`storage_error` (which includes inability to generate/confirm enrollment).
Enrollment result values are `enrolled`, `code_or_room_rejected`,
`answer_rejected` (an answer the Room's question does not offer), `name_taken`,
`full` and `storage_error` (which includes inability to generate/confirm enrollment).
`enrolled` is recorded only after a Participant create is confirmed, including
recovery of a lost create response. Returning participants do not inflate it.
It is a process-local counter, not the current number of Participants in Kubernetes.
Expand Down
4 changes: 2 additions & 2 deletions internal/server/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ func TestMetricsReflectOutcomesWithoutIdentityLabels(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := s.enrollParticipant(context.Background(), "WRONG", "private-name"); err == nil {
if _, err := s.enrollParticipant(context.Background(), "WRONG", "private-name", ""); err == nil {
t.Fatal("invalid code accepted")
}
if _, err := s.enrollParticipant(context.Background(), "BCDFGH", "private-name"); err != nil {
if _, err := s.enrollParticipant(context.Background(), "BCDFGH", "private-name", ""); err != nil {
t.Fatal(err)
}
for _, path := range []string{"/healthz", "/unknown-private-name?code=SECRET", "/another-random-path"} {
Expand Down
Loading
Loading